以及 EIP-2930 引入的訪問清單(Access List)功能應如何使用?

原文標題:《乾貨 | 搞懂 “柏林” 之後的合約 Gas 開銷》
撰文:Franco Victorio
翻譯 & 校對:阿劍

「柏林」硬分叉已於 4 月 15 日激活,該硬分叉所包含 EIP 中的兩個(EIP-2929 和 EIP-2930)都會影響事務的 Gas 開銷。本文會解釋 “柏林” 激活之前,一些操作碼的 Gas 消耗量是如何計算的,而 EIP-2929 對此有何影響,以及,2930 引入的訪問清單(Access List)功能應如何使用。

摘要

這篇文章很長,你要是隻想知道結論,看完這部分就可以把網頁關掉了:

  • 柏林硬分叉改變了某些操作碼的 Gas 開銷。如果你在自己的應用中硬編碼了一些操作可使用的 Gas 數量,這些操作可能會卡死。如果真的出現了這種情況,而你的智能合約又是沒法升級的,用戶就需要使用 “訪問清單” 功能來使用你的應用。

  • 訪問清單功能可略微減少 Gas 開銷,但有些時候也可能會提高總的 Gas 消耗量。

  • geth 客戶端引入了一種新的 RPC 方法,叫做 eth_createAccessList 來簡化訪問清單的生成。

「柏林」升級以前的 Gas 開銷

EVM 所執行的每一個操作碼都有一個對應的 Gas 消耗量。大部分操作碼的消耗量都是固定的:PUSH1 總是消耗 3 gas,而 MUL 消耗 5 gas,等等。有一些操作碼的消耗量是可變的:舉個例子,SHA3 操作碼的開銷由輸入值的長度決定。

我們先了解 SLOADSSTORE 操作碼,因爲這兩個操作碼受 “柏林” 影響最大。後面我們會再談談那些以地址爲目標的操作,比如所有的 EXT* 類操作碼和 CALL* 類操作碼,因爲它們的 Gas 開銷也被改變了。

「柏林」 以前的 SLOAD

在 EIP-2929 實施前,SLOAD 開銷的計算方式很簡單:總是消耗 800 gas。所以,也沒啥可展開的。

「柏林」以前的 SSTORE**

要講到 Gas 消耗量的計算,SSTORE 操作碼可能是最複雜的了。因爲消耗多少取決於該存儲項槽當前的值、要寫入的新值、該存儲項是否已經修改過。我們只會分析少數幾種場景,瞭解個大概。如果你想了解更多,請閱讀本文末尾所附的 EIP 鏈接。

  • 如果存儲項的值從 0 改爲 1 (或者任意非零的值),Gas 消耗量是 20000

  • 如果存儲項的值從 1 改爲 2 (或者任意非零的值),Gas 消耗量是 5000

  • 如果存儲項的值從 1 (或任意非零的值) 改爲 0,消耗量也是 5000,但你會在事務執行結束後獲得 gas 補貼。我們這裏也不討論 gas 返還機制,因爲它不會受到柏林的影響

  • 在一筆事務中,如果存儲項已不是第一次修改,則後續每一次 SSTORE 都消耗 800 gas

細節在這裏並不重要,重要的是,SSTORE 是昂貴的,具體消耗多少 gas 則依賴於多個因素。

EIP-2929 之後的 Gas 消耗量

EIP-2929 改變了所有這些數值。但在展開之前,我們要先談談該 EIP 引入的一個重要概念:被訪問過的地址被訪問過的存儲項的鍵(storage key)

當一個地址或者一個存儲項的鍵,在一筆事務中被 “使用過” 之後,在該筆交易餘下的執行過程中,這個地址(或者這個鍵)都會被當成 “已被訪問過的”。舉個例子,如果你在一筆事務中 CALL (調用)另一個合約,那麼該合約的地址就會被標記爲 “訪問過的”。類似地,如果你 SLOAD 或者 SSTORE 過一些存儲項槽 ,在該筆事務餘下的執行過程裏,這些槽也會被當成已經訪問過的。到底用的哪個操作碼是沒有關係的,即使你只 SLOAD 過某個槽,接下來使用 SSTORE 時該槽也會被當成已訪問過的。

注意:存儲項的鍵是 “內在於” 某些地址中的,一如該 EIP 所解釋的:

執行事務時,保持一個集合:accessed_addresses: Set[Address] 以及 accessed_storage_keys: Set[Tuple[Address, Bytes32]]

也就是說,當我們說某個存儲槽已被訪問過了,我們的實際意思是:(address, storageKey) 已被訪問過了。

搞清楚了這個概念,我們來談談新的 Gas 消耗量計算模式。

「柏林」以後的 SLOAD

升級前,SLOAD 的 Gas 消耗量是固定的 800。但升級後,Gas 消耗量要看這個存儲槽是否已經被訪問過。還沒訪問過的,消耗量就是 2100 gas;訪問過的,就是 100 gas。所以,如果某個存儲項槽已經在 “已訪問過的存儲項鍵 ` 的集合裏了,就可以省掉 2000 gas。

「柏林」以後的 SSTORE

我們逐個逐個對比下,在 EIP-2929 實施後,上面的幾個例子會發生什麼樣的變化:

  • 如果存儲項的值從 0 改爲 1 (或者任意非零的值),Gas 消耗量是 20000

    • 如果該存儲項鍵還未訪問過,消耗 22100 gas

    • 若已訪問過,消耗 20000 gas

  • 如果存儲項的值從 1 改爲 2 (或者任意非零的值),Gas 消耗量是 5000

    • 如果該存儲項鍵還未訪問過,消耗 5000 gas

    • 若已訪問過,消耗 2900 gas

  • 如果存儲項的值從 1 (或任意非零的值) 改爲 0,消耗量保持不變,gas 返還機制也不變

  • 在一筆事務中,如果存儲項已不是第一次修改,則後續每一次 SSTORE 都消耗 100 gas

由此可見,如果某個槽此前已訪問過,則對它的第一次 SSTORE 操作會節約 2100 gas (相比於從未訪問過)。

彙總一下

上面的文字實在囉嗦,我們就直接做一張表,把上面提到的值都彙總一下:

柏林硬分叉如何影響以太坊交易的 Gas 費開銷?

注意看最後一行:此時已不再需要區分它到底有沒有被訪問過,因爲,如果此前已寫入,則必定已被訪問過。

EIP-2930:可選 「訪問清單」的事務類型

另一個 “柏林” 升級包含的 EIP 是 2930。該 EIP 加入了一種新的類型的事務,可以在事務的負載中包含一個 “訪問清單”,意思是,你可以在事務執行前就聲明哪些地址和存儲槽應被認爲是 “訪問過的”。舉個例子,對一個未訪問過的槽執行 SLOAD 需要耗費 2100 gas,但如果該存儲槽被包含在了事務的 “訪問清單” 中,則操作的消耗量機會降爲 100 gas。

但如果只要地址和槽被當成 “已訪問過的” 就可以降低操作的 Gas 消耗量;而訪問清單可以把地址和槽標記爲 “已訪問過的”;那豈不是說我們可以把這些東西都放在訪問清單中,來獲得 Gas 消耗量的減免?真棒,天賜 Gas!

額,並不完全如此,因爲你每添加一個地址或存儲項鍵,都要支付額外的 Gas。

舉個例子。假如我們要向合約 A 發送了一條事務。我們編寫了一條這樣的訪問清單:

柏林硬分叉如何影響以太坊交易的 Gas 費開銷?

如果我們發送了一條帶有這條訪問清單的事務,而使用 0x0 存儲槽的第一個操作碼就是 SLOAD,則 Gas 消耗量會是 100 而非 2100,也就是減免了 2000 gas。但是,在訪問列表中聲明一個存儲項鍵需要額外支付 1900 gas,所以我們只節約了 100 gas。(如果對該存儲槽的第一個操作是 SSTROE,我們在單個操作中就省下了 2100 gas,也就是總共省下了 200 gas,因爲訪問清單本身需要消耗 gas)。

這是不是說,每次使用訪問清單我們都能節省 gas 呢?很遺憾,也不是,因爲在訪問清單中填入地址也需要支付 gas。(也就是我們示例中的 ""

訪問過的地址

迄今爲止,我們只討論了 SLOADSSTORE 操作碼,但 “柏林” 升級還改變了別的操作碼。舉個例子,CALL 操作碼原來的 Gas 消耗量爲固定的 700,但 2929 實施後,如果所調用的地址不在訪問清單中,消耗量將提高到 2600;如果在,則降低爲 100。而且,就像訪問過的存儲鍵一樣,到底哪個操作碼訪問過那個地址並不重要(比如,如果用戶最先調用的是 EXTCODESIZE,這一個操作的消耗量是 2600,但後續的調用,只要是對同一個地址的,無論是 EXTCODESIZECALL 還是 STATICCALL ,都只消耗 100 gas。

那個這個設計對帶有訪問清單的事務有何影響?假設我們向合約 A 發送一條交易,而合約 A 調用了合約 B,而我們在訪問清單中寫入這樣的內容:
柏林硬分叉如何影響以太坊交易的 Gas 費開銷?

我們首先需要爲在這條事務的訪問清單中加入這個地址支付 2400 gas,但對 B 使用的第一個操作碼就只需要消耗 100 gas 而不是 2600 gas,這就剩下了 100 gas。如果 B 也需要使用其存儲項,我們又知道它將使用哪個鍵,我們也可以把這些鍵包含在訪問列表中,然後爲每個鍵的操作省下 100 或 200 gas (取決於第一個操作碼是 SLOAD 還是 SSTORE)。

但爲啥我們要加多一個合約來舉例子?我們不是可以這樣寫嗎?
柏林硬分叉如何影響以太坊交易的 Gas 費開銷?

你當然可以這樣做,但不值得,因爲 EIP-2929 指明瞭你一開始調用的合約(也即是 tx.to 的目的地)必定會被包含在 accessed_addresses 列表中,所以你就是額外花了 2400 gas,什麼好處都沒得到。

所以,回頭看我們上面舉的例子:
柏林硬分叉如何影響以太坊交易的 Gas 費開銷?

這樣做其實是浪費,除非你在裏面加多幾個存儲項鍵。如果我們假設所有的存儲項鍵的第一個操作都是 SLOAD,那你要至少 24 個鍵,才能賺回來。

而且,如你所見,自己一五一十地分析這些因素、手動生成訪問清單,顯然是極其繁瑣而令人崩潰的事。好在,還有更好的辦法。

eth_createAccessList RPC 方法

Geth 客戶端(從 1.10.2)開始將包含一個新的 eth_createAccessList RPC 方法,你可以用它來生成訪問清單,就像使用 eth_estimateGas 一樣,只不過返回的不是 Gas 消耗量估計,而是形如這樣的數據:

柏林硬分叉如何影響以太坊交易的 Gas 費開銷?

也就是告訴你一筆事務將會用到的地址和存儲項鍵的清單,以及,_假定納入這份訪問清單_將耗用多少 gas。跟 eth_estimateGas 一樣,這也是估計出來的,該筆事務真正上鍊時,會訪問到哪些數據仍有可能改變。但是,再說一遍,這絕不意味着你只要使用了訪問清單,所用的 Gas 就會比不用清單更少!

我估計隨着時間推移,我們會越來越知道怎麼利用這個功能,但我個人估計,方法的僞代碼形式會像這樣:
柏林硬分叉如何影響以太坊交易的 Gas 費開銷?

防止合約變磚

值得提醒,訪問清單功能的主要目的不是節省 Gas。如該 EIP 自身所述:

緩解由 EIP-2929 帶來的合約變磚風險,因爲事務可以預先指定、預先支付自身嘗試範文的賬戶和存儲槽,因此,在實際的執行中,SLOAD 和 EXT* 操作碼都只會消耗 100 gas:這個值低到既足以防止 2929 打破某些合約,也可以 “解封” 被 EIP-1884 封印的合約。

原本,只要一個合約預設了執行的 Gas 開銷,操作碼的 Gas 消耗量變動就有可能導致它變磚。比如,如果一個合約預設另一個合約的 someFunction 只會用到 34500 gas,因此總是用 someOtherContract.someFunction{gas: 34500}() 調用那個合約,這個合約就有可能變磚。但只要你在事務中添加合適的訪問清單,這個合約就還能工作。

自己驗證

如果你想自己測試一下,克隆這個倉庫,這裏面有很多例子,可以使用 Hardhat 和 Geth 客戶端來運行。請仔細閱讀 README。

參考文獻

  • EIP-2929 和 EIP-2930 是兩個跟本文有關的 “柏林” EIP。

  • EIP-2930 依賴於 “柏林” 升級納入的另一個 EIP:EIP-2718,也叫標準化的事務信封。

  • EIP-2929 大量參考了 EIP-2200,如果你想更深入地理解 Gas 消耗量,你應該從那裏開始。

來源鏈接:hackmd.io