Yige

Yige

Build

Solidity 合約升級

合約代理升級#

底層調用(Call /DelegateCall)#

  • Call:調用目標合約的函數,並在目標合約的上下文中執行,但調用者合約的存儲狀態不會改變
  • DelegateCall:調用目標合約的函數,並在調用者合約的上下文中執行,調用者合約的存儲狀態會改變

底層概念#

  • EVM 不關心狀態變量,而是在存儲槽上操作,所有的變量狀態變化,都是對應於存儲 slot 上的內容變更,所以需要注意 slot 衝突,也就是當前合約和目標合約的 slot 槽位佈局不能衝突

  • 一個合約對目標智能合約進行 delegatecall 時,會在自己的環境中執行目標合約的邏輯 (相當於把目標合約的代碼複製到當前合約中執行)

合約創建#

創建合約有幾種主要的實現方式:

  1. 直接部署
contractA = new contractA(....)
  1. 工廠模式,通過一個工廠合約來操作,有利於批量創建相似的合約,並可以實現一些額外的邏輯或限制
contract ContractFactory {
	function createContract(uint _value) public returns (address) {
		SimpleContract newContract = new SimpleContract(_value);
		return address(newContract);
	}
}
  1. 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)));
	}
}
  1. 最小代理模式(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))
		}
	}
}
  1. 代理模式(可升級合約):創建可升級的合約,允許在不改變合約地址的情況下更新合約邏輯,適用於需要長期維護和更新的複雜系統

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
	}
}

構造函數的問題#

  1. 為了防止實現合約被直接初始化。在代理模式中,我們希望只有代理合約能夠調用初始化函數,而不是實現合約本身,我們需要定義一個普通函數,通常叫 initialize 去替代構造函數,但因為普通函數可以重複調用,所以同時需要保證這個函數只能被調用一次
  2. 如果繼承了父合約,也需要保證父合約的初始化函數只能在初始化時被調用一次
  3. 可以在構造函數中調用 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));
	}
}

因此,需要禁止在邏輯合約中使用 selfdestructdelegatecall,以太坊社區正在討論完全移除 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
UUPSLogic 合約不會更複雜

參考鏈接#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。