合約代理升級#
底層調用(Call /DelegateCall)#
- Call:調用目標合約的函數,並在目標合約的上下文中執行,但調用者合約的存儲狀態不會改變
- DelegateCall:調用目標合約的函數,並在調用者合約的上下文中執行,調用者合約的存儲狀態會改變
底層概念#
-
EVM 不關心狀態變量,而是在存儲槽上操作,所有的變量狀態變化,都是對應於存儲 slot 上的內容變更,所以需要注意 slot 衝突,也就是當前合約和目標合約的 slot 槽位佈局不能衝突
-
一個合約對目標智能合約進行 delegatecall 時,會在自己的環境中執行目標合約的邏輯 (相當於把目標合約的代碼複製到當前合約中執行)
合約創建#
創建合約有幾種主要的實現方式:
- 直接部署
contractA = new contractA(....)
- 工廠模式,通過一個工廠合約來操作,有利於批量創建相似的合約,並可以實現一些額外的邏輯或限制
contract ContractFactory {
function createContract(uint _value) public returns (address) {
SimpleContract newContract = new SimpleContract(_value);
return address(newContract);
}
}
- create2 操作碼:可以提前預測合約地址,從而實現更複雜的部署策略,如延遲部署
contract Create2Factory {
function deployContract(uint _salt, uint _value) public returns (address){
return address(new SimpleContract{salt: bytes32(_salt)}(_value));
}
function predictAddress(uint _salt, uint _value) public view returns (address) {
bytes memory bytecode = abi.encodePacked(type(SimpleContract).creationCode, abi.encode(_value));
bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff),
address(this),
_salt,
keccak256(bytecode)
)
);
return address(uint160(uint(hash)));
}
}
- 最小代理模式(EIP-1167):創建非常輕量級的代理合約,指向一個已部署的實現合約,可以大大節省 gas
contract MinimalProxy {
function clone(address target) external returns (address result) {
// 將目標合約的地址轉換為 bytes20
bytes20 targetBytes = bytes20(target);
// 最小代理的字節碼
bytes memory minimalProxyCode = abi.encodePacked(
hex"3d602d80600a3d3981f3363d3d373d3d3d363d73",
targetBytes,
hex"5af43d82803e903d91602b57fd5bf3"
);
// 使用 create 部署新的代理合約
assembly {
result := create(0, add(minimalProxyCode, 0x20), mload(minimalProxyCode))
}
}
}
- 代理模式(可升級合約):創建可升級的合約,允許在不改變合約地址的情況下更新合約邏輯,適用於需要長期維護和更新的複雜系統
contract UpgradeableContract is Initializable {
uint public value;
function initialize(uint _value) public initializer {
value = _value;
}
}
contract ProxyFactory {
function deployProxy(address _logic, address _admin, bytes memory _data) public returns (address) {
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(_logic, _admin, _data);
return address(proxy);
}
}
合約升級需要注意的問題#
變量問題#
-
升級合約時不要隨意變更變量,包括類型、定義順序等,如果新添加的合約變量,可以在末尾添加
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1)
,用於將槽位指定到一個不會發生衝突的位置 -
需要保證合約中的變量在
initialize
函數中初始化,而不是通過構造函數初始化,比如
contract MyContract {
uint256 public hasInitialValue = 42;
}
就需要改成:
contract MyContract is Initializable {
uint256 public hasInitialValue;
function initialize() public initializer {
hasInitialValue = 42; // set initial value in initializer
}
}
構造函數的問題#
- 為了防止實現合約被直接初始化。在代理模式中,我們希望只有代理合約能夠調用初始化函數,而不是實現合約本身,我們需要定義一個普通函數,通常叫
initialize
去替代構造函數,但因為普通函數可以重複調用,所以同時需要保證這個函數只能被調用一次 - 如果繼承了父合約,也需要保證父合約的初始化函數只能在初始化時被調用一次
- 可以在構造函數中調用
disableInitializers
函數:constructor() { _disableInitializers();}
,
作用在於:
- 禁用初始化構造函數,防止惡意攻擊者直接調用實現合約的初始化函數,導致狀態被意外或惡意修改
- 在升級過程中,新的實現合約不應該被初始化,因為它會繼承之前版本的狀態。禁用初始化器可以防止在升級過程中意外重新初始化合約
selfdestruct 問題#
通過代理合約,不會直接和底層邏輯合約交互,即使存在惡意行為者直接向邏輯合約發送交易,也沒關係,因為我們是通過代理合約管理存儲狀態資產,底層邏輯合約的存儲狀態變更都不會影響代理合約,但有一點需要注意:
如果邏輯合約觸發了 selfdestruct
操作,那麼邏輯合約將被銷毀,代理合約將無法繼續使用,因為 delegatecall
將無法調用已經銷毀的合約
同樣,如果邏輯合約中包含了一個可以被外部控制的 delegatecall
,那麼這可能被惡意利用。攻擊者可能會讓這個 delegatecall
指向一個包含 selfdestruct 的惡意合約,由於是通過 delegatecall
調用的惡意合約, selfdestruct
是在邏輯合約的上下文中執行的,它實際上會銷毀調用它的合約(在這種情況下也就是邏輯合約),也將導致邏輯合約被銷毀。
舉個例子:
// 攻擊者能夠將 implementation 設置為 MaliciousContract 的地址,然後調用 delegateCall 函數並傳入 destroy 函數的調用數據
// 導致 LogicContract 執行 MaliciousContract 的 destroy 函數,從而銷毀 LogicContract 自身
contract LogicContract {
address public implementation;
function setImplementation(address _impl) public {
implementation = _impl;
}
function delegateCall(bytes memory _data) public {
(bool success, ) = implementation.delegatecall(_data);
require(success, "Delegatecall failed");
}
}
contract MaliciousContract {
function destroy() public {
selfdestruct(payable(msg.sender));
}
}
因此,需要禁止在邏輯合約中使用 selfdestruct
或 delegatecall
,以太坊社區正在討論完全移除 selfdestruct
的可能性
透明代理和 UUPS 代理#
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
// 簡單的可升級合約,管理員可以通過升級函數更改邏輯合約地址,從而改變合約的邏輯。
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;
}
}
以上是一個最簡單可升級的代理合約,它通過 delegatecall
將所有調用委託給邏輯合約,同時定義了一個upgrade()
函數,從而實現了合約的代理升級,但這裡存在一個問題,通過 delegatecall
調用傳參都是函數選擇器(selector),它是函數簽名的哈希的前 4 個字節,因此需要一種機制來避免這種衝突,這就引出了透明代理和 UUPS 代理
透明代理#
透明代理的邏輯非常簡單:管理員可能會因為 “函數選擇器衝突”,在調用邏輯合約的函數時,誤調用代理合約的可升級函數。那麼限制管理員的權限,不讓他調用任何邏輯合約的函數,就能解決衝突:
- 管理員變為工具人,僅能調用代理合約的可升級函數對合約升級,不能通過回調函數調用邏輯合約。
- 其它用戶不能調用可升級函數,但是可以調用邏輯合約的函數
可以參考 openzeppelin 中的實現 TransparentUpgradeableProxy
UUPS 代理#
透明代理的邏輯簡單,但也存在一個問題,每次用戶調用函數時,都会多一步是否為管理員的檢查,消耗更多 gas,這就引出了另一種方案 UUPS 代理
UUPS(universal upgradeable proxy standard,通用可升級代理)將升級函數放在邏輯合約中,這樣一來,如果有其它函數與升級函數存在 “選擇器衝突”,編譯時就會報錯
參考鏈接: UUPS
總結#
普通可升級合約,透明代理,和 UUPS 的不同點:
標準 | 升級函數在 | 是否會 “選擇器衝突” | 缺點 |
---|---|---|---|
可升級代理 | Proxy 合約 | 會 | 選擇器衝突 |
透明代理 | Proxy 合約 | 不會 | 費 gas |
UUPS | Logic 合約 | 不會 | 更複雜 |