Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

EIP-1822 与 UUPS 可升级代理

UUPS(Universal Upgradeable Proxy Standard)的核心是把升级逻辑放在实现合约中。用户始终访问代理地址,代理通过 delegatecall 执行当前实现的代码;升级时,更换代理记录的实现地址,保留代理地址和已有状态。

示例采用 Solidity 0.8.24、OpenZeppelin Contracts 与 Contracts Upgradeable 5.4.0。升级入口、初始化函数与上下文检查按该版本定义。

代理保存状态,实现提供代码

一个 UUPS 实例至少涉及两个合约:

合约主要职责状态所在位置
代理(Proxy)保存实现地址,将调用委托给实现业务数据、升级权限及实现地址等保存在代理的存储中
实现(Implementation)提供业务函数和升级函数的代码直接调用实现地址时,访问的是实现自己的存储

通过代理执行 delegatecall 时,address(this) 是代理地址,读写的是代理存储,msg.sender 保留代理收到这次调用时的调用者。因而实现代码中的 owner()、value 和实现地址槽,都是在代理的上下文中读取或修改。

这也解释了升级的过程:管理员调用代理地址上的升级接口,代理委托给当前实现;当前实现检查权限,再把代理的实现地址槽更新为新地址。代理字节码本身不需要改变。

直接调用实现合约的 initialize(),即便能改变实现自己的 owner,也不等于取得代理的 owner。必须区分调用地址与执行上下文,才能判断一个操作究竟影响哪份状态。

EIP-1822 与 OpenZeppelin 的关系

EIP-1822 提出了 UUPS 架构及 proxiableUUID() 兼容接口。原提案使用 keccak256("PROXIABLE") 作为实现地址槽;OpenZeppelin 的现代 UUPS 实现则采用 EIP-1967 规定的实现地址槽。

因此,采用 UUPS 思路并不意味着任意实现都可以互相升级。双方还必须约定相同的存储槽与升级机制。upgradeToAndCall() 是OpenZeppelin 5.4.0的升级入口,不能把它当作 EIP-1822 对所有实现规定的唯一函数名。

OpenZeppelin 升级时会调用新实现的 proxiableUUID(),要求返回预期的实现地址槽。这是兼容性检查,不能证明新代码没有漏洞,也不能证明业务存储布局兼容。

与透明代理的区别

比较项透明代理UUPS
升级逻辑位于代理侧,通常由 ProxyAdmin 管理位于实现合约中,通过代理执行
管理调用与业务调用代理按管理员身份区分调用路径实现中的升级接口负责权限检查
部署开销代理侧包含更多管理机制代理侧通常更轻,具体成本取决于版本和部署方式
后续升级能力由代理侧的管理机制提供依赖当前实现仍然提供可用的升级路径

UUPS 减少了代理层的管理接口,但升级函数仍与业务函数一起存在于实现中,接口设计和权限控制仍然需要审查。透明代理的管理员存储方式也会随版本变化,不能统一断言每次调用都额外读取同一个管理员槽。

一个最小实现

以下示例使用 Solidity 0.8.24 和 OpenZeppelin 5.4.0。V1 保存一个数值,owner 可以修改数值或升级;V2 在原有状态布局上增加一个递增函数。

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

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyLogicV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 public value;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) public initializer {
        __Ownable_init(initialOwner);
        __UUPSUpgradeable_init();
    }

    function setValue(uint256 newValue) external onlyOwner {
        value = newValue;
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

contract MyLogicV2 is MyLogicV1 {
    function increment() external onlyOwner {
        value += 1;
    }
}

部署时先创建 V1,再创建 ERC1967Proxy。将 abi.encodeCall(MyLogicV1.initialize, (initialOwner)) 作为代理构造函数的初始化数据,使部署代理与设置 owner 在同一笔交易中完成。之后,业务调用和升级调用都发送到代理地址。

升级到 V2 时,owner 通过代理调用 upgradeToAndCall(address(v2), "")。这里传入空数据,因为 V2 没有需要初始化的新状态。升级后,代理的 value 和 owner 保留,代理也能执行新增的 increment()。

如果新版本增加了需要初始化的状态,可设计受控的 reinitializer,并通过升级调用附带初始化数据。普通 V1 initializer 不应在每次升级后重新执行。

初始化与调用上下文保护

初始化保护分为两件事:构造函数中的 _disableInitializers() 锁定实现合约本身;代理部署时执行的 initialize() 设置代理的状态。前者不会替代理初始化,后者也不会锁定实现自己的状态。

OpenZeppelin 5.4.0 提供两种上下文检查:

  • onlyProxy:升级入口必须通过代理委托执行,而且代理当前必须指向这个实现。
  • notDelegated:proxiableUUID() 必须直接在实现地址上调用,不能通过代理调用,避免把代理本身作为新实现而形成委托循环。

因此,不能把“直接初始化实现合约”推导为“直接修改代理的升级地址”。旧版本曾有与未初始化实现相关的攻击路径,但需要结合具体代码、调用路径和链的规则分析,不能套用到所有 UUPS 实现。

升级时真正需要保证的条件

升级权限决定谁可以替换代码。示例使用 owner,实际项目也可以使用多签或治理合约。获得升级权限通常意味着能够改变整个系统的行为,不能只把它理解为维护某个配置项。

新实现还必须兼容已有存储。对示例中的普通状态变量,不能随意改变 value 的类型、位置或含义。OpenZeppelin 的兼容检查不会自动发现所有存储冲突,部署工具的布局检查和升级后的行为验证仍然有必要。

当前实现会拒绝没有正确 proxiableUUID() 的普通合约。但一个返回正确 UUID 的实现仍可能删除升级入口、拒绝所有升级,或者引入错误。因此,兼容检查也不保证未来一定还能升级。

如果希望最终停止升级,应明确设计并验证终止机制。意外失去升级路径,可能让代理继续执行业务,却无法再修复代码;这与“代理一定完全无法使用”并不是同一件事。

参考资料