合约代理升级

底层调用(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

钻石代理

核心概念

  • 钻石(Diamond) 主合约,提供唯一的入口点,用户通过该合约调用功能。 它保存所有模块的路由信息。

  • 模块(Facet) 各种功能模块的集合,每个模块可以实现特定的逻辑或功能。 可以动态地增加、删除或替换模块。

  • 选择器表(Selector Table) 每个函数的选择器(selector,即函数签名的哈希值)与对应模块地址的映射。 主合约根据选择器找到对应的模块并将调用委托给它

升级和管理

开发者可以通过一个特殊的接口(diamondCut 函数)动态管理模块:

  • 添加模块:将新模块加入选择器表。
  • 替换模块:将选择器指向新的模块地址。
  • 删除模块:从选择器表中移除模块

参考实现

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

// 钻石存储
contract Diamond {
    struct Facet {
        address facetAddress;
        bytes4[] selectors;
    }

    mapping(bytes4 => address) public selectorToFacet;

    // 添加/替换模块
    function diamondCut(Facet[] memory facets) external is Admin {
        for (uint256 i = 0; i < facets.length; i++) {
            Facet memory facet = facets[i];
            for (uint256 j = 0; j < facet.selectors.length; j++) {
                selectorToFacet[facet.selectors[j]] = facet.facetAddress;
            }
        }
    }

    // 委托调用
    fallback() external payable {
        address facet = selectorToFacet[msg.sig];
        require(facet != address(0), "Function not found");

        assembly {
            let ptr := mload(0x40)
            calldatacopy(ptr, 0, calldatasize())
            let result := delegatecall(gas(), facet, ptr, calldatasize(), 0, 0)
            let size := returndatasize()
            returndatacopy(ptr, 0, size)

            switch result
            case 0 {
                revert(ptr, size)
            }
            default {
                return(ptr, size)
            }
        }
    }
}

或者可以参考官方开发者的仓库实现: https://github.com/mudgen/Diamond

总结

特性 普通可升级合约 透明代理(Transparent Proxy) UUPS(EIP-1822) 钻石代理(EIP-2535)
设计目标 实现最简单的合约升级 解决管理复杂性和存储冲突问题 优化升级逻辑,减少代理合约耦合度 支持模块化、动态扩展的可升级合约
代理合约的职责 存储逻辑地址并转发调用 存储逻辑地址,转发调用,同时限制管理员权限 存储逻辑地址,仅转发调用 存储模块选择器与对应模块地址的映射
逻辑合约升级方式 修改存储槽中逻辑地址 管理员通过代理合约函数修改逻辑地址 逻辑合约自身提供 upgradeTo 函数 使用 diamondCut 管理模块
存储槽 无标准,易冲突 使用 EIP-1967 标准存储槽 使用 EIP-1967 标准存储槽 每个模块独立管理存储
升级逻辑的位置 手动升级逻辑,容易出错 升级逻辑在代理合约中 升级逻辑在实现合约中 升级逻辑在主合约中
功能分布 单一逻辑合约 单一逻辑合约 单一逻辑合约 多个模块(Facet)
代码复杂性 简单实现,但风险高 较复杂,需要管理员角色处理权限分离 较简单,适合轻量化合约 复杂,实现模块化与动态扩展
升级灵活性 灵活,但可能破坏存储 灵活,安全性更高 灵活,但依赖逻辑合约 极其灵活,可动态添加/替换模块
性能 高效 高效 高效 因多模块路由,性能略低
适用场景 小型测试项目或一次性部署合约 DeFi 协议、大型系统 轻量化合约,适合简单场景 复杂、模块化系统,如 DeFi 或游戏合约

参考链接