【译】Diving Into The Ethereum VM Part 4 - How To Decipher A Smart Contract Method Call

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

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

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

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

事务对于智能合同来说是HTTP请求对于Web服务。


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

合同交易

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

 杂注扎实0.4.11; 
 合同C { 
  uint256 a; 
 函数setA(uint256 _a){ 
  a = _a; 
  } 
 函数getA()返回(uint256){ 
 返回一个; 
  } 
  } 

该合同部署在测试网络Rinkeby上。 随意使用Etherscan在地址0x62650ae5 ...处检查它。

我创建了一个可以调用setA(1)的事务。 在地址0x7db471e5 ...处检查此事务。

交易的输入数据是:

  0xee919d500000000000000000000000000000000000000000000000000000000000000001 

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

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

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

前四个字节是方法选择器。 输入数据的其余部分是32字节块的方法参数。 在这种情况下,只有1个参数,值为0x1

方法选择器是方法签名的kecccak256散列。 在这种情况下,方法签名是setA(uint256) ,它是方法的名称和参数的类型。

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

  #安装pyethereum https://github.com/ethereum/pyethereum/#installation 
  >从ethereum.utils导入sha3 
  > sha3(“setA(uint256)”).hex() 
  'ee919d50445cd9f463621849366a537968fe1ce096894b0d0c001528383d4769' 

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

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

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

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

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

如果EVM上的语言都同意应该如何解释输入数据,那么他们可以很容易地相互操作。 合同应用程序二进制接口 (ABI)指定了一种通用编码方案。

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

调用Getter

如果您正在调用的方法更改状态,则整个网络必须同意。 这将需要一笔交易,并且花费你的燃气。

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

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 ”\ 
  --data' 
  { 
  “jsonrpc”:“2.0”, 
  “id”:1, 
  “method”:“eth_call”, 
  “params”:[ 
  { 
  “to”:“0x62650ae5c5777d1660cc17fcd4f48f6a66b9a4c2”, 
  “data”:“0xd46300fd” 
  }, 
  “最新” 
  ] 
  } 
  

EVM执行计算并返回原始字节:

  { 
  “jsonrpc”: “2.0”, 
  “ID”:1, 
  “结果”: “0x0000000000000000000000000000000000000000000000000000000000000001” 
  } 

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

用于外部方法调用的程序集

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

 杂注扎实0.4.11; 
 合同C { 
  uint256 a; 
  //注意:`应付'使组件更简单一些 
 函数setA(uint256 _a)应付{ 
  a = _a; 
  } 
  } 

编译:

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

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

  sub_0:程序集{ 
  mstore(0x40,0x60) 
 和(div(calldataload(0x0),0x100000000000000000000000000000000000000000000000000000000),0xffffffff) 
  0xee919d50 
  DUP2 
  EQ 
  TAG_2 
  jumpi 
  TAG_1: 
 为0x0 
  DUP1 
 还原 
  TAG_2: 
  tag_3 
  calldataload(为0x4) 
 跳(tag_4) 
  tag_3: 
 停止 
  tag_4: 
  / *“call.sol”:95:96 a * / 
 为0x0 
  / *“call.sol”:95:101 a = _a * / 
  DUP2 
  swap1 
  sstore 
  tag_5: 
 流行的 
 跳出来 
  auxdata:0xa165627a7a7230582016353b5ec133c89560dea787de20e25e96284d67a632e9df74dd981cc4db7a0a0029 
  } 

有两个样板代码与本次讨论无关,但仅供参考:

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

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

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

首先,用于匹配选择器的带注释的程序集:

  //加载前4个字节作为方法选择器 
 和(div(calldataload(0x0),0x100000000000000000000000000000000000000000000000000000000),0xffffffff) 
  //如果选择器匹配`0xee919d50`,转到setA 
  0xee919d50 
  DUP2 
  EQ 
  TAG_2 
  jumpi 
  //没有匹配的方法。 失败并恢复。 
  TAG_1: 
 为0x0 
  DUP1 
 还原 
  // setA的正文 
  TAG_2: 
  ... 

除了在开始从呼叫数据加载4个字节的比特洗牌之外,它是直截了当的。 为了清楚起见,低级别伪代码中的汇编逻辑如下所示:

  methodSelector = calldata [0:4] 
 如果methodSelector ==“0xee919d50”: 
  goto tag_2 // goto setA 
 其他: 
  //没有匹配的方法。 失败并恢复。 
 还原 

实际方法调用的带注释的程序集:

  // setA 
  TAG_2: 
  //方法调用后到哪里去 
  tag_3 
  //加载第一个参数(值为0x1)。 
  calldataload(为0x4) 
  //执行方法。 
 跳(tag_4) 
  tag_4: 
  // sstore(0x0,0x1) 
 为0x0 
  DUP2 
  swap1 
  sstore 
  tag_5: 
 流行的 
  //程序结束后,会转到tag_3并停止 
  
  tag_3: 
  //程序结束 
 停止 

在进入方法体之前,程序集有两件事:

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

在低级伪代码中:

  //保存方法调用后返回的位置。 
  @returnTo = tag_3 
  tag_2:// setA 
  //将调用数据的参数加载到堆栈上。 
  @ arg1 = calldata [4:4 + 32] 
  tag_4:// a = _a 
  sstore(0x0,@ arg1) 
  tag_5 //返回 
 跳(@returnTo) 
  tag_3: 
 停止 

将两部分组合在一起:

  methodSelector = calldata [0:4] 
 如果methodSelector ==“0xee919d50”: 
  goto tag_2 // goto setA 
 其他: 
  //没有匹配的方法。 失败。 
 还原 
  @returnTo = tag_3 
  tag_2:// setA(uint256 _a) 
  @ arg1 = calldata [4:36] 
  tag_4:// a = _a 
  sstore(0x0,@ arg1) 
  tag_5 //返回 
 跳(@returnTo) 
  tag_3: 
 停止 
有趣的琐事:恢复的操作码是 fd 但是你不会在黄皮书中找到它的规范,或者在代码中实现。 实际上, fd 实际上并不存在! 这是一个无效的操作。 当EVM遇到无效操作时,它会放弃并恢复状态作为副作用。

处理多种方法

Solidity编译器如何为具有多个方法的合同生成装配体?

 杂注扎实0.4.11; 
 合同C { 
  uint256 a; 
  uint256 b; 
 函数setA(uint256 _a){ 
  a = _a; 
  } 
 函数setB(uint256 _b){ 
  b = _b; 
  } 
  } 

简单。 更多的if-else分支一个接一个:

  // methodSelector = calldata [0:4] 
 和(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] 
 如果methodSelector ==“0x9cdcf9b”: 
  goto tag_2 
  elsif methodSelector ==“0xee919d50”: 
  goto tag_3 
 其他: 
  //找不到匹配的方法。 失败。 
 还原 

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

不要担心零。 没关系。

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

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

首先,导入encode_abi函数:

 从ethereum.abi导入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字节的块(如果需要,填充0),依次排列:

  > 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编码

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

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

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

  > encode_abi( 
  [“uint256 []”,“uint256 []”,“uint256 []”], 
  [[0xa1,0xa2,0xa3],[0xb1,0xb2,0xb3],[0xc1,0xc2,0xc3]] 
  ).HEX() 
  / ************* HEAD(32 * 3字节)************* / 
  // arg1:查看阵列数据的位置0x60 
  0000000000000000000000000000000000000000000000000000000000000060 
  // arg2:查看数组数据的位置0xe0 
  00000000000000000000000000000000000000000000000000000000000000e0 
  // arg3:查看阵列数据的位置0x160 
  0000000000000000000000000000000000000000000000000000000000000160 
  / ************* TAIL(128 ** 3个字节)************* / 
  //位置0x60。  arg1的数据。 
  //长度后跟元素。 
  0000000000000000000000000000000000000000000000000000000000000003 
  00000000000000000000000000000000000000000000000000000000000000a1 
  00000000000000000000000000000000000000000000000000000000000000a2 
  00000000000000000000000000000000000000000000000000000000000000a3 
  //位置0xe0。 数据为arg2。 
  0000000000000000000000000000000000000000000000000000000000000003 
  00000000000000000000000000000000000000000000000000000000000000b1 
  00000000000000000000000000000000000000000000000000000000000000b2 
  00000000000000000000000000000000000000000000000000000000000000b3 
  //位置0x160。  arg3的数据。 
  0000000000000000000000000000000000000000000000000000000000000003 
  00000000000000000000000000000000000000000000000000000000000000c1 
  00000000000000000000000000000000000000000000000000000000000000c2 
  00000000000000000000000000000000000000000000000000000000000000c3 

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

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

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

  > encode_abi( 
  [“uint256”,“uint256 []”,“uint256”], 
  [0xaaaa,[0xb1,0xb2,0xb3],0xbbbb] 
  ).HEX() 
  / ************* HEAD(32 * 3字节)************* / 
  // arg1:0xaaaa 
  000000000000000000000000000000000000000000000000000000000000aaaa 
  // arg2:查看阵列数据的位置0x60 
  0000000000000000000000000000000000000000000000000000000000000060 
  // arg3:0xbbbb 
  000000000000000000000000000000000000000000000000000000000000bbbb 
  / ************* TAIL(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( 
  [“串”], 
  [“a”*(32 + 16)] 
  ).HEX() 
  0000000000000000000000000000000000000000000000000000000000000020 
  //字符串的长度是0x30(48) 
  0000000000000000000000000000000000000000000000000000000000000030 
  6161616161616161616161616161616161616161616161616161616161616161 
  6161616161616161616161616161616100000000000000000000000000000000 

嵌套阵列

嵌套数组每个嵌套有一个间接方向。

  > encode_abi( 
  [ “uint256 [] []”], 
  [[[0xa1,0xa2,0xa3],[0xb1,0xb2,0xb3],[0xc1,0xc2,0xc3]]] 
  ).HEX() 
  // arg1:outter数组位于位置0x20。 
  0000000000000000000000000000000000000000000000000000000000000020 
  // 0x20。 每个元素都是内部数组的位置。 
  0000000000000000000000000000000000000000000000000000000000000003 
  0000000000000000000000000000000000000000000000000000000000000060 
  00000000000000000000000000000000000000000000000000000000000000e0 
  0000000000000000000000000000000000000000000000000000000000000160 
  // 0x60处的数组[0] 
  0000000000000000000000000000000000000000000000000000000000000003 
  00000000000000000000000000000000000000000000000000000000000000a1 
  00000000000000000000000000000000000000000000000000000000000000a2 
  00000000000000000000000000000000000000000000000000000000000000a3 
  //数组[1]在0xe0 
  0000000000000000000000000000000000000000000000000000000000000003 
  00000000000000000000000000000000000000000000000000000000000000b1 
  00000000000000000000000000000000000000000000000000000000000000b2 
  00000000000000000000000000000000000000000000000000000000000000b3 
  //数组[2]在0x160 
  0000000000000000000000000000000000000000000000000000000000000003 
  00000000000000000000000000000000000000000000000000000000000000c1 
  00000000000000000000000000000000000000000000000000000000000000c2 
  00000000000000000000000000000000000000000000000000000000000000c3 

雅,很多零。

气体成本和ABI编码设计

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

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

  • 每笔交易支付21000美元。
  • 4支付每个零字节的数据或代码进行交易。
  • 对于交易的每个非零字节的数据或代码支付68。

啊哈! 零点便宜17倍,因此零点填充并不像看起来那么糟糕。

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

  • 填充到32字节的0x1花费192个气体。

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

  • sha256很可能有32个非零字节,这大约需要2176个气体

32 * 68

  • 截断为4字节的sha256将花费约272气体

32 * 4

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

负整数...

负整数通常使用称为Two's Complement的方案来表示。 int8编码的值-1将全部为1 1111 1111

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

  ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 

小负数主要是1秒,因此耗费相当多的天然气。

¯\ _(ツ)_ /¯

结论

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

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

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

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

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


在这篇关于EVM的文章系列中,我写到:


https://medium.com/@hayeah/how-to-decipher-a-smart-contract-method-call-8ee980311603

猜你喜欢

转载自blog.csdn.net/omnispace/article/details/80345239