OpenZeppelin 可昇級合約插件 5/9 寫可昇級的合約
撰寫可昇級的合約
在使用 OpenZeppelin 的可昇級合約插件來寫 Solidity 合約之前,有一些注意事項要先記得。
值得一提的是,這些限制來自於以太坊虛擬機的工作原理。這些限制適用於所有的可昇級合約項目,而不只是 OpenZeppelin 的可昇級合約。
初始化
可以在OpenZeppelin可昇級合約插件內直接使用原來的 Solidity 寫的合約,只需要修改建構函數即可。由於這種基於代理的可昇級系統,不能在可昇級的合約內使用建構函數。想了解這個限制背後的理由,可以參考 https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies#the-constructor-caveat 。
這表示,在 OpenZeppelin 可昇級合約插件裡使用智能合約,必需把合約的建構函數修改成一般函數,一般命名為 initialize ,如下例
// NOTE: Do not use this code snippet, it's incomplete and has a critical vulnerability! pragma solidity ^0.6.0; contract MyContract { uint256 public x; function initialize(uint256 _x) public { x = _x; } }
然而,Solidity 會確保 constructor 在合約的生命週期內只被調用一次,一般函數可以被調用多次。為了預防合約被初始化多次,必需在 initialize 函數內加入檢查,確保只被調用一次。
// contracts/MyContract.sol // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; contract MyContract { uint256 public x; bool private initialized; function initialize(uint256 _x) public { require(!initialized, "Contract instance has already been initialized"); initialized = true; x = _x; } }
由於這種模式在可昇級合約中非常通用,OpenZeppelin 可昇級合約插件提供 Initializable 基礎合約,使用 initializer 修飾符來完成這個模式
// contracts/MyContract.sol // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; import "@openzeppelin/upgrades/contracts/Initializable.sol"; contract MyContract is Initializable { uint256 public x; function initialize(uint256 _x) public initializer { x = _x; } }
建構函數 constructor 和一般函數之間的另一個不同點,就是 Solidity 會自動調用本合約的所有父合約的建構函數。當寫 initializer 的時候,要手動去調用父合約的 initializer 函數
// contracts/MyContract.sol // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; import "@openzeppelin/upgrades/contracts/Initializable.sol"; contract BaseContract is Initializable { uint256 public y; function initialize() public initializer { y = 42; } } contract MyContract is BaseContract { uint256 public x; function initialize(uint256 _x) public initializer { BaseContract.initialize(); // Do not forget this call! x = _x; } }
使用可昇級智能合約程式庫
在 initial 函數初始化的限制,不止在合約裡要注意,連引入的程式庫也是如此。舉個例子,OpenZeppelin 的 ERC20 合約,這個合約在建構函數裡面初始化代幣的名稱,符號和小數位。
// @openzeppelin/contracts/token/ERC20/ERC20.sol pragma solidity ^0.6.0; ... contract ERC20 is Context, IERC20 { ... string private _name; string private _symbol; uint8 private _decimals; constructor (string memory name, string memory symbol) public { _name = name; _symbol = symbol; _decimals = 18; } ... }
以上表示這些合約不適用在可昇級合約插件的專案內使用。替代的方案是使用 @openzeppelin/contracts-ethereum-package , 這是官方分支的 OpenZeppelin合約,修改建構函數成為 initializer ,看一下 ERC20 合約在 @openzeppelin/contracts-ethereum-package 長什麼樣子
// @openzeppelin/contracts-ethereum-package/contracts/token/ERC20/ERC20.sol pragma solidity ^0.6.0; ... contract ERC20UpgradeSafe is Initializable, ContextUpgradeSafe, IERC20 { ... string private _name; string private _symbol; uint8 private _decimals; function __ERC20_init(string memory name, string memory symbol) internal initializer { __Context_init_unchained(); __ERC20_init_unchained(name, symbol); } function __ERC20_init_unchained(string memory name, string memory symbol) internal initializer { _name = name; _symbol = symbol; _decimals = 18; } ... }
不論是使用 OpenZeppelin 合約或是其他合約程式庫,記得確定這些程式套件是不是可以符合可昇級合約的條件。
避免在欄位聲明時使用初始值
Solidity 在合約內總是在宣告變數時就定義了初始值
contract MyContract { uint256 public constant hasInitialValue = 42; }
這種方式等同於在建構函數內設定這些值,像這樣的方式也不能用在可昇級合約裡。確定所有的初始值寫在 initializer 函數之中,不然的話可昇級的合約實體就沒有這些變數的設定值。
contract MyContract is Initializable { uint256 public hasInitialValue; function initialize() public initializer { hasInitialValue = 42; } }
注意到這種方式來定義常量也是很好,因為編譯器不會預先留下存儲空間,而是在每次使用時,透過運算常量運算式來取得這個常量。有關常量可以參考 Solidity 官網文件 https://solidity.readthedocs.io/en/latest/contracts.html#constant-and-immutable-state-variables 。所以以下的常量定義可以用在可昇級合約。
contract MyContract { uint256 public constant hasInitialValue = 42; }
從智能合約代碼建立新的合約實體
直接從合約代碼建立一個新的合約實體時,這些創建過程是由 Solidity 來掌握,而不是 OpenZeppelin 可昇級合約插件,這表示這些合約實體不是可昇級的。
例如以下的例子,就算 MyContract 是部署為可昇級合約,但是 token 合約實體仍然不是可昇級的。
// contracts/MyContract.sol // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; import "@openzeppelin/upgrades/contracts/Initializable.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MyContract is Initializable { ERC20 public token; function initialize() public initializer { token = new ERC20("Test", "TST"); // This contract will not be upgradeable } }
如果想讓 ERC20 實體是可昇級的,最簡單達成的方式就是在建立實體完成之後,把實體當成參數注入合約內。代碼如下
// contracts/MyContract.sol // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; import "@openzeppelin/contracts-ethereum-package/contracts/Initializable.sol"; import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/IERC20.sol"; contract MyContract is Initializable { IERC20 public token; function initialize(IERC20 _token) public initializer { token = _token; } }
潛在的不安全操作
使用可昇級的合約時,通常是跟上層的合約實體交互,而不是跟底層的邏輯合約交互。但是不能防止惡意的人送交易給邏輯合約。這不構成威脅,因為底層的邏輯合約上的狀態,並不會被上層的合約實體使用,所以不會影響上層的合約實體。
但還是有例外,如果直接調用邏輯合約的 selfdestruct 操作,邏輯合約就會被刪除。所有使用這個邏輯合約的上層實體合約,將會把調用委託給這個沒有任何代碼的地址。這就會中斷專案中所有調用到的合約。
另外一個類似效果的操作,就是邏輯合約內使用了 delegatecall 操作,如果可以讓合約內的 delegatecall ,調用一個內含 selfdestruct 操作的惡意合約,也會刪除調用的合約。
因此,不允許在合約內使用 selfdestruct 或是 delegatecall 。
修改合約
在寫合約的新版本時,不論是增加新功能或是修改問題,有一些額外的限制需要注意,就是不能更改已經宣告的合約狀態變數的順序,或是類型。這些限制背後的原因,可以參考 https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies (這是我們的下一篇,代理昇級模式 Proxy Upgrade Pattern)
特別注意,違反任何這些合約狀態變數的順序及類型限制,會導致合約的昇級版本把這些狀態變數值混用,而導致應用程式嚴重錯誤。
假設已經宣告的合約是以下的順序宣告合約變數
contract MyContract { uint256 private x; string private y; }
以下是因為 x 的類型被修改了,所以產生錯誤
contract MyContract { string private x; string private y; }
以下是因為 x 跟 y 的順序被修改了,所以會產生錯誤
contract MyContract { string private y; uint256 private x; }
以下的代碼是因為 a 變數宣告時放在 x 及 y 之前,影響了順序所以產生錯誤
contract MyContract { bytes private a; uint256 private x; string private y; }
以下的代碼則是因為刪除了 x 變數,影響了順序所以產生錯誤
contract MyContract { string private y; }
如果需要增加新變數,可以加在最後,如下的 z 變數,加在所有已宣告的變數之後
contract MyContract { uint256 private x; string private y; bytes private z; }
請記得如果重命名變數的話,那變數內容跟昇級之前會相同,
contract MyContract { uint256 private x; string private z; // 內容跟昇級前的 y 相同 }
如果從後面刪除變數的話,如果之後再新增一個變數,那這個變數會跟原來被刪除的內容一樣。
以下是在昇級時刪除 y 變數
contract MyContract { uint256 private x; }
如果未來又再昇級,增加了 z 變數,那z 的值就是 y 刪除前的值。
contract MyContract { uint256 private x; string private z; // z 的值是之前刪的 y 變數的值 }
有可能在修改繼承關時,不經意的更改了變數的順序及類型,以下 MyContract 是繼承 A,B 合約
contract A { uint256 a; } contract B { uint256 b; } contract MyContract is A, B {}
把繼承順序改為 B,A 時,也是修改了變數的順序及類型。
contract MyContract is B, A {}
如果子合約已經宣告變數了,就不能在基礎合約上增加新的變數,想一下以下的場景
contract Base { uint256 base1; } contract Child is Base { uint256 child; }
當 Base 合約新增了一個 base2 變數,base2變數的值就變成了 Child 合約的 child 變數
contract Base { uint256 base1; uint256 base2; }
這個現象的解決方案,是在基礎合約內預先宣告幾個未使用的變數,佔用變數的位置,以便未來擴展。這個方式不會增燃料的使用量。
參考
https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable