死磕以太坊原始碼分析之EVM如何呼叫ABI編碼的外部方法

mindcarver發表於2021-02-25

死磕以太坊原始碼分析之EVM如何呼叫ABI編碼的外部方法

配合以下程式碼進行閱讀:https://github.com/blockchainGuide/

寫文不易,給個小關注,有什麼問題可以指出,便於大家交流學習。

image-20210113191423657

前言

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…上檢視該交易。

交易的輸出資料是:

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_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

有趣的小細節: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個元素。

混合動態和靜態引數是可能的。這裡有個(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

翻譯自 https://medium.com/@hayeah/diving-into-the-ethereum-vm-part-2-storage-layout-bc5349cb11b7

相關文章