死磕以太坊源碼分析之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
的設置者和獲取者:
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…上查看該交易。
交易的輸出數據是:
0xee919d500000000000000000000000000000000000000000000000000000000000000001
對於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_call
RPC請求可以允許你在本地模擬交易。這對於只讀方法或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
在進入方法體之前,匯編代碼做了兩件事情:
- 保存了一個位置,方法調用之后返回此位置
- 從調用數據里面加載參數到棧中
低級的偽代碼:
// 保存位置,方法調用結束后返回此位置
@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
有趣的小細節:
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.
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個元素。
混合動態和靜態參數是可能的。這里有個(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
編碼字節數組
字符串和字節數組同樣是頭尾編碼。唯一的區別是字節數組會被緊密的打包成一個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。
翻譯自 https://medium.com/@hayeah/diving-into-the-ethereum-vm-part-2-storage-layout-bc5349cb11b7