深入了解以太坊虚拟机第4部分——ABI编码外部方法调用的方式
本文由幣乎社區(bihu.com)內容支持計劃贊助。
在本系列的上一篇文章中我們看到了Solidity是如何在EVM存儲器中表示復雜數據結構的。但是如果無法交互,數據就是沒有意義的。智能合約就是數據和外界的中間體。
在這篇文章中我們將會看到Solidity和EVM可以讓外部程序來調用合約的方法并改變它的狀態。
“外部程序”不限于DApp/JavaScript。任何可以使用HTTP RPC與以太坊節點通信的程序,都可以通過創建一個交易與部署在區塊鏈上的任何合約進行交互。
創建一個交易就像發送一個HTTP請求。Web的服務器會接收你的HTTP請求,然后改變數據庫。交易會被網絡接收,底層的區塊鏈會擴展到包含改變的狀態。
交易對于智能合約就像HTTP請求對于Web服務器。
如果對EVM匯編和Solidity數據表示陌生,請閱讀該系列的前幾篇文章:
本系列文章其他部分譯文鏈接:
- EVM匯編代碼的介紹(第1部分)
- 固定長度數據類型的表示方法(第2部分)
- 動態數據類型的表示方法(第3部分)
合約交易
讓我們來看一下將狀態變量設置在0x1位置上的交易。我們想要交互的合約有一個對變量a的設置者和獲取者:
pragma solidity ^0.4.11; contract C {uint256 a;function setA(uint256 _a) {a = _a;}function getA() returns(uint256) {return a;} }這個合約部署在Rinkeby測試網上??梢噪S意使用Etherscan,并搜索地址 0x62650ae5…進行查看。
我創建了一個可以調用setA(1)的交易,可以在地址0x7db471e5...上查看該交易。
交易的輸出數據是:
0xee919d500000000000000000000000000000000000000000000000000000000000000001對于EVM而言,這只是36字節的元數據。它對元數據不會進行處理,會直接將元數據作為calldata傳遞給智能合約。如果智能合約是個Solidity程序,那么它會將這些輸入字節解釋為方法調用,并為setA(1)執行適當的匯編代碼。
輸入數據可以分成兩個子部分:
# 方法選擇器(4字節) 0xee919d5 #第一個參數(32字節) 00000000000000000000000000000000000000000000000000000000000000001前面的4個字節是方法選擇器,剩下的輸入數據是方法的參數,32個字節的塊。在這個例子中,只有一個參數,值是0x1。
方法選擇器是方法簽名的 kecccak256 哈希值。在這個例子中方法的簽名是setA(uint256),也就是方法名稱和參數的類型。
讓我們用Python來計算方法選擇器。首先,哈希方法簽名:
然后獲取哈希值的前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"]}'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 --optimize call.sol調用方法的匯編代碼在合約內部,在sub_0標簽下:
sub_0: assembly {mstore(0x40, 0x60)and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)0xee919d50dup2eqtag_2jumpitag_1:0x0dup1reverttag_2:tag_3calldataload(0x4)jump(tag_4)tag_3:stoptag_4:/* "call.sol":95:96 a */0x0/* "call.sol":95:101 a = _a */dup2swap1sstoretag_5:popjump // 跳出 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:0x0dup1revert // 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)0x0dup2swap1sstore tag_5:pop//程序的結尾,將會跳轉到 tag_3并停止jump tag_3:// 程序結尾stop在進入方法體之前,匯編代碼做了兩件事情:
低級的偽代碼:
// 保存位置,方法調用結束后返回此位置 = tag_3 tag_2: // setA// 從調用數據里面加載參數到棧中1 = calldata[4:4+32] tag_4: // a = _asstore(0x0, 1) tag_5 // 返回jump() tag_3:stop將這兩部分組合起來:
methodSelector = calldata[0:4] if methodSelector == "0xee919d50":goto tag_2 // goto setA else:// 無匹配方法。失敗revert = tag_3 tag_2: // setA(uint256 _a)1 = calldata[4:36] tag_4: // a = _asstore(0x0, 1) tag_5 // 返回jump() 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) // 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.revertABI為復雜方法調用進行編碼
不用擔心零,這些零都沒關系對于一個方法調用,交易輸入數據的前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 0000000000000000000000000000000000000000000000000000000000000006ABI為動態數組編碼
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 00000000000000000000000000000000000000000000000000000000000000c3HEAD部分有32字節參數,指出TAIL部分的位置,TAIL部分包含了3個動態數組的實際數據。
舉個例子,第一個參數是0x60,指出調用數據的第96個(0x60)字節。如果你看一下第96個字節,它是數組的開始地方。前32字節是長度,后面跟著的是3個元素。
混合動態和靜態參數是可能的。這里有個(static,dynamic,static)參數。靜態參數按原樣編碼,而第二個動態數組的數據放到了尾部:
> 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有很多的0,不過沒關系。
編碼字節數組
字符串和字節數組同樣是頭尾編碼。唯一的區別是字節數組會被緊密的打包成一個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很多的0!
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。
本系列文章其他部分譯文鏈接:
- EVM匯編代碼的介紹(第1部分)
- 固定長度數據類型的表示方法(第2部分)
- 動態數據類型的表示方法(第3部分)
- 一個新合約被創建后會發生什么(第5部分)
翻譯作者: 許莉
原文地址:Diving Into The Ethereum VM Part Four
作者:Lilymoana
鏈接:https://www.jianshu.com/p/d0e8e825d41b
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
總結
以上是生活随笔為你收集整理的深入了解以太坊虚拟机第4部分——ABI编码外部方法调用的方式的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 深入了解以太坊虚拟机第3部分——动态数据
- 下一篇: 深入了解以太坊虚拟机第5部分——一个新合