邏輯和存儲分離#
我們都知道,智能合約部署到區塊鏈上後合約的代碼是無法篡改的,一旦合約出現 bug 項目方很多時候也無計可施
合約通常由存儲的變量和邏輯函數組成,因為遷移變量到另一個新合約上開銷太大,並且主要升級的就是邏輯函數,因此可以將存儲的變量和邏輯函數進行合約層面上的隔離
因此完整的調用鏈路如下圖所示:
- user 與 contract-A 交互,A 中不記錄函數邏輯,只存儲變量
- contract-A 通過 delegatecall 調用 contract-B,將 B 中的函數邏輯作用於 contract-A
- 調用結束,在 user 視角中只有 contract-A,不感知 contract-B 的存在
基礎的可升級合約實現#
參照上面的調用鏈路,如果將 contract-B 替換為其他合約,即可完成邏輯合約的更替而不影響原來的合約存儲
proxy 合約#
- implementation:記錄了邏輯合約地址
- 利用 fallback 函數,將函數調用全部通過 delegatecall 轉移到邏輯合約上
- 提供 upgrade 函數用於更換邏輯合約地址
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
// 簡單的可升級合約,管理員可以通過升級函數更改邏輯合約地址,從而改變合約的邏輯。
contract SimpleUpgrade {
address public implementation; // 邏輯合約地址
address public admin; // admin地址
string public words; // 字符串,可以通過邏輯合約的函數改變
// 構造函數,初始化admin和邏輯合約地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// fallback函數,將調用委託給邏輯合約
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// 升級函數,改變邏輯合約地址,只能由admin調用
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
logic 合約#
- 需要保持和 proxy 合約相同的存儲佈局(變量類型、變量順序)
- 邏輯合約中的函數將會將變量的修改作用到 proxy 合約上,因此調用
foo()
函數後將會將 proxy 中的words
變量修改為old
contract Logic1 {
// 狀態變量和proxy合約一致,防止插槽衝突
address public implementation;
address public admin;
string public words; // 字符串,可以通過邏輯合約的函數改變
// 改變proxy中狀態變量,選擇器: 0xc2985578
function foo() public{
words = "old";
}
}
新的邏輯合約:
- 保持一致的存儲佈局
- 調整函數邏輯,完成邏輯的更新,調用
foo()
函數後將會將 proxy 中的words
變量修改為new
contract Logic2 {
// 狀態變量和proxy合約一致,防止插槽衝突
address public implementation;
address public admin;
string public words; // 字符串,可以通過邏輯合約的函數改變
// 改變proxy中狀態變量,選擇器:0xc2985578
function foo() public{
words = "new";
}
}
合約升級#
admin 賬戶調用 proxy 合約中的upgrade
方法,傳入新的邏輯合約地址,即可完成升級
存在的問題#
在 fallback 中調用 delegatecall 可能會出現 selector 衝突的問題
函數的選擇器 selector 就是函數簽名的哈希前四個字節,不同的函數是存在衝突的可能性的,如果合約中存在兩個選擇器衝突的函數時,合約是無法完成編譯的,但是 proxy 合約和邏輯合約之間可能存在衝突,在編譯環節無法檢測
透明代理#
透明代理就是為了解決選擇器衝突而導致的升級問題,因此透明代理的思路是將 upgrade 函數的權限和 fallback 中執行 delegatecall 的權限隔離開
- fallback 函數,要求不是 admin 調用
- upgrade 函數,要求必須是 admin 調用
以此避免因函數選擇器衝突導致的升級問題
fallback() external payable {
require(msg.sender != admin);
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// 升級函數,改變邏輯合約地址,只能由admin調用
function upgrade(address newImplementation) external {
if (msg.sender != admin) revert();
implementation = newImplementation;
}
通用可升級代理 UUPS#
透明代理的模式每次都需要校驗管理員地址,耗費 gas
UUPS 模式將升級的函數放在邏輯合約裡,在單合約中可在編譯時就避免升級函數的選擇器衝突問題
原先的升級函數是在 proxy 合約中,且限制為 admin 權限,現在挪到 logic 合約中,由於 delegatecall 過程中 msg.sender 是不變的,而且存儲的 admin 依舊是 proxy 合約中記錄的,因此這個校驗邏輯放在 logic 合約中一樣可行
UUPS 升級合約示例如下:在邏輯合約中記錄 implementation 地址,並作用到 proxy 合約中
proxy 合約#
只有存儲的變量、構造函數和 fallback,去掉了升級函數
contract UUPSProxy {
address public implementation; // 邏輯合約地址
address public admin; // admin地址
string public words; // 字符串,可以通過邏輯合約的函數改變
// 構造函數,初始化admin和邏輯合約地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// fallback函數,將調用委託給邏輯合約
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
}
logic 合約#
增加了升級函數
// UUPS邏輯合約(升級函數寫在邏輯合約內)
contract UUPS1{
// 狀態變量和proxy合約一致,防止插槽衝突
address public implementation;
address public admin;
string public words; // 字符串,可以通過邏輯合約的函數改變
// 改變proxy中狀態變量,選擇器: 0xc2985578
function foo() public{
words = "old";
}
// 升級函數,改變邏輯合約地址,只能由admin調用。選擇器:0x0900f010
// UUPS中,邏輯函數中必須包含升級函數,不然就不能再升級了。
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
// 新的UUPS邏輯合約
contract UUPS2{
// 狀態變量和proxy合約一致,防止插槽衝突
address public implementation;
address public admin;
string public words; // 字符串,可以通過邏輯合約的函數改變
// 改變proxy中狀態變量,選擇器: 0xc2985578
function foo() public{
words = "new";
}
// 升級函數,改變邏輯合約地址,只能由admin調用。選擇器:0x0900f010
// UUPS中,邏輯函數中必須包含升級函數,不然就不能再升級了。
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}