Solidity 教學: 最佳化合約 Contract Optimization

本篇預計閱讀時間 20 分鐘。

Gas 與 Gas Cost

首先我們可以觀察智能合約被佈署之後,在 Console window debugger 中的資訊:

  • 主要有三項內容比較重要:

    • gas: operation 的複雜度

    • transaction cost: 已經包含在 execution cost 中

    • execution cost

  • 只要我們選擇了較高的 Gas Fee 或讓合約的 Gas 較多,則交易速度就會更快,因為從 Mine(Space Complexity) Level,礦工會更傾向選擇我們的交易進行打包。

Space Complexity Level

Gas 與 Operational Costs

Gas 和汽車耗油的概念一樣,每輛車在理想情況下(不會折舊)的單位耗油量、油箱容量是不變的,然而油價是會起伏的。同理,已佈署合約的 Gas Consumption 是固定不變的,真正導致每次佈署同樣合約(程式碼)、呼叫相同函式的 Gas fee 都不一樣的原因來自於 Gas Price 是不斷變動的。

如果真的得徹底了解合約中耗費的 Gas,我們可以利用 REMIX Debugging Step Detail 取得合約到底執行了哪些 Operation,再利用 Ethereum Yellow Paper 中的定義計算 Gas Consumption。也就是說實際上是 Operational Complexity 決定了 Gas Cost

資料儲存處

我們遇到的問題是在以太坊區塊鏈中儲存資料實在是太貴了,所以想要找到一個便宜儲存資料的方法。目前根據儲存位置的不同,有幾種解決方式:

  • 將資料儲存在鏈外,只儲存 proof(hash),例如:Notary

  • 儲存在分散式資料庫,例如: IPFS

  • 雜湊樹 Merkle Tree

  • 從底層的角度來看,我們已經把 Event 的內容儲存在編譯並交易的區塊上了,何不直接利用這些 Event 來查看和呼叫資料。

利用事件節省的想法是: 如果這些資料並不需要被智能合約直接使用,那我們就只要把它放在 log 裡就好。

也就衍伸出兩種想法: Store Path on Cahin vs. Store String in Events。

參考文章 Storing on Ethereum. Analyzing the costs

圖 17-1 Gas costs

圖 17-1

不過還是得避免無用的 Event 宣告,因為即便 Event 是相對便宜 的,其參數和宣告都需要耗費 Gas。無論是 Event、Function、加密演算法(ripemd160、sha256、keccak256)的參數多寡都會影響到 Gas,所以在撰寫時務必力求精簡。

也就是說選擇資料儲存處的概念是那些資料和計算是必須 On- Chain 的,那些是不必的,如果是已知的計算結果或 Constant,甚至是 Metadata、相關檔案、參數或相關設定資料,便可以選擇在合約(鏈)外計算完畢,最後只要想辦法導入結果即可。

Time Complexity Level

遍歷 (iterate) 或修改陣列中的元素是非常昂貴的。使用 mapping 會是較好的選擇,我們必須要在知道自己正做什麼的情況下才選擇 Array !

同時在進行迴圈運算、條件運算時最好選擇 Local Variable 進 行運算,最後再把結果賦值給狀態變數。

同樣地例子可以運用在邏輯運算上,當我們使用 && 和 || 時,前述的運算結果若已經可決定整個敘述的結束,就不會再繼續運算下去。因此配置 AND 和 OR 的時候也可以稍微想想有沒有什麼更早結束運算的可能。

Contract Level

從另外一個角度來看,我們真的必須要用 OpenZeppelin 所定義的一切合約撰寫模式來實作項目嗎?

或許它的安全性是經過一些人保證過的。但如果我們去觀察一下合約裡面的內容,是能夠發現一些省下 Gas Fee 關鍵的。

像是我們在區塊鏈上「儲存一個狀態變數」耗費的 Gas 是 20000 gas(從前小節的資料可知),我們在觀察 OpenZeppelin ERC721. sol 後可以發現以下變數宣告其實是在徒增我們的 Gas !。

當然不是指這些變數宣告是毫無作用的,只是在佈署合約時得要確認所有變數、合約都是我們要的。

如果有重複功能的函式或變數可選擇模組化,將其打包在 Library 中以此來解決重複佈署的問題。(因為 Library 運作上等同於直接將一段程式碼複製貼上在指定處)

如何減少 gas 的耗費:

  • 減少鏈上的資料

  • 使用 event 而非 storage

  • 最佳化變數宣告

White List 白名單

白名單大概是我們最常使用的一個「應用」了,因為無論是什麼項目都希望可以對客群進行分層,不管是增加向心力、提升稀有度等原因,能對使用者階級化是一個產品頗為重要的重點。

平常使用的最原始方法大概如下(程式碼參考 Albert Lin/CoinsBench)

 

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.11;

import "@openzeppelin/contracts/token/ERC721/extensions/ ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract PrimitiveWhiteList is ERC721Enumerable, Ownable {

uint256 public constant MINT_PRICE = 0.1 ether;
mapping(address => bool) public whitelist;
constructor() ERC721("Primitive Whitelist", "PW") {}
function whitelistMint(uint256 amount) external payable {
 require(msg.value == amount * MINT_PRICE, "Ether send below price");
require(whitelist[msg.sender], "Not in whitelist");

// start minting
uint256 currentSupply = totalSupply();
for (uint256 i = 1; i <= amount; i++) {
 _safeMint(msg.sender, currentSupply + i);
}
}

function addWhitelist(address _newEntry) external onlyOwner { whitelist[_newEntry] = true;
}
function removeWhitelist(address _newEntry) external onlyOwner {
require(whitelist[_newEntry], "Previous not in whitelist");
whitelist[_newEntry] = false;
}
}

這樣的一個合約十分直觀,寫起來容易至極且具有彈性,但最大的缺點就是複雜度過高,計算過程沒有效率。這導致 Deployer,通常也是合約的擁有者、項目方會浪費很多 Gas 在佈署上。

而第一個改良的方法便是:雜湊樹 Merkle Tree。

圖 17-1 雜湊樹

雜湊樹也會被稱做 Hash Tree,也就是說他的值會儲存 hash 值,而葉子是資料塊的 hash 值,通常是資料(或資料集)進行 hash 之後的結果。非葉節點的值是其下葉子節點和他自己的值 hash 後的結果。驗證方法是利用可信的樹根(root)來對下方節點(分支)進行驗證。

Merkle Tree 具有大部分樹的特質,包含 Binary Tree 等,由於樹是可長的,在早期階段的參與者可避免掉高額 Public Mint 的 Gas Fee。

換句話說我們可以在合約上鏈前就使用鏈下資源(像是其他程式語言)計算完白名單的Merkle Tree 結果,然後只把結果放在合約中。

使用上的優點是檢驗白名單的成本降低,間接導致佈署與擁有者的 Gas Fee降低;缺點是如果需要重新建立白名單需要把根部位整個換掉,以及後來的參與者會有比原先還稍微高的 Mint Gas Fee。這部分的實際操作請查看 參考資料

另外一個改良方法則為 Backend Signature,把白名單用戶的 Address 放在後端,在使用者決定要 Mint 時開發者需要在後端檢查一次他們的簽名。

原理為在我們使用私鑰進行交易之後產生一段 hash 過的訊息,利用這段訊息和 hash 前的交易訊息即可產生一個公鑰。如果我們將公鑰儲存在合約中並以我們的私鑰交易後即可確保沒人能夠偽造交易訊息。若是要防止重送攻擊還可以在加密過程中融入 nonce。

舉例來說如果現在有個白名單上的用戶想要 mint,而我們之前已經在資料庫裡儲存他們的地址了。此時當用戶在 DApp 上 mint 時,我們需要他們先sign 一段訊息,並且使用 ecRecover 或 verifyMessage 在後端檢驗他們的送來的訊息。如果成功和資料庫裡的配對上了,那就 sign 一個「通行證」給用戶,讓他們能憑此和智能合約互動。

使用上的優點是可非常容易地管理白名單,對於開發者來說佈署與操作成本都降低不少;缺點是 Mint Gas Fee 提高,且去中心化程度降低。這部分的實際操作請查看 參考資料

Low Level and Others

實作上還有其他更低階的方法可以使佈署、操作合約的 Gas Fee 降低,提供一些例子,歡迎大家有興趣一起研究。

  • Solidity Optimization

  • Variable Declaration & Pack Variables(bytes32 vs. string vs. bytes)

  • Assembly Code

  • 將 modifier 的可視性定義為 external

  • 使用 Proxy 來進行 Multiple Deploy