透明可升级代理

文章详细解释了透明可升级代理模式,该模式旨在升级代理时消除函数选择器冲突的可能性。文章介绍了代理合约的基本需求、函数选择器冲突问题及其解决方案,并通过代码示例和图表深入探讨了OpenZeppelin的实现细节。

透明可升级代理模式详细解释

透明可升级代理是一种设计模式,用于在升级代理的同时消除函数选择器冲突的可能性。

一个功能齐全的以太坊代理至少需要以下两个特性:

  • 一个存储槽,保存实现合约的地址
  • 一个机制,允许管理员更改实现地址

ERC-1967 标准规定了实现地址应存储的位置,以最小化存储碰撞的机会。然而,ERC-1967 标准并没有规定如何更改实现地址。

将一个额外的函数放置在代理中以更改实现(例如 updateImplementation(address _newImplementation))的问题在于,更新函数有非忽略的机会与实现中的某个函数发生冲突。

函数选择器冲突

在代理中声明公共函数来更新实现地址会引入函数选择器冲突的可能性。

这里是一个简单的例子:

contract ProxyUnsafe {

    function changeImplementation(
            address newImplementation
    ) public {
        // 一些代码...
    }

    fallback(bytes calldata data) external payable (bytes memory) {
        (bool ok, bytes memory data) = getImplementation().delegatecall(data);
        require(ok, "delegatecall 失败");
        return data;
    }
}

contract Implementation {
    // 这里声明了一个相同的函数 -- 它们将发生冲突
    function changeImplementation(
            address newImplementation
    ) public {

    }
    //...
}

请记住,fallback 始终最后检查。 在调用 fallback 之前,代理合约会检查 4 字节函数选择器是否匹配 changeImplementation(或代理中的任何其他公共函数)。

因此,如果在代理中声明了一个公共函数,则可能发生两种类型的函数选择器冲突:

  1. 如果实现合约实现了一个具有相同签名的函数,则该函数将无法调用,因为代理的公共函数会被调用,而不是 fallback。如果不触发 fallback,就不会有对实现的 delegatecall
  2. 如果实现合约有一个与代理中的公共函数相同的函数选择器,它也将无法调用,原因相同。这种情况是一种随机的函数选择器冲突,当四个字节匹配时可能会发生。两个不同的函数具有相同选择器的概率是 1 在 42.9 亿中;函数选择器由 4 个字节组成,因此有 42.9 亿种可能性。这是一个小概率,但不是可忽略的。例如,clash550254402()proxyAdmin() 具有相同的函数选择器。

透明可升级代理模式完全防止函数选择器冲突

透明可升级代理模式是一种设计模式,旨在完全消除函数选择器冲突的可能性。

具体来说,透明可升级代理模式规定代理上不应该有公共函数,除了 fallback

但是只有一个 fallback 函数,我们如何调用用于升级代理的函数呢?

答案是检测 msg.sender 是否是管理员。

contract Proxy is ERC1967 {
    address immutable admin;

    constructor(address admin_) {
        admin = admin_;
    }

    fallback() external payable {
        if (msg.sender == admin) {
            // 升级逻辑
        } else {
            // delegatecall 到实现
        }
    }
}

这意味着管理员无法直接使用代理,因为他们的调用总是被路由到升级逻辑。然而,使用我们稍后会讨论的不同机制,管理员仍然可以调用代理,代理作为普通交易进行到实现的 delegatecall。

更改不可变的管理员

在上述代码片段中,管理员是不可变的。这意味着合约在技术上不符合 ERC-1967 的要求,后者规定管理员必须保存在存储槽 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 或者 bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) 中。

为了兼容 ERC-1967,透明可升级代理在该存储槽中存储管理员的地址,但并不使用该存储变量。

该存储槽中地址的存在将向区块探测器发出信号,表明合约是一个代理(这是 ERC-1967 的一个意图)。然而,每次对代理的调用都从存储中读取,增加了额外的 2100 Gas成本。因此,使用不可变变量是可取的。

“更改”管理员

然而,仍然希望能够更新管理员地址 — 但最初这似乎是不可能的,因为代理使用了一个不可变变量。

透明可升级代理允许更改代理合约管理员的方式有两个方面。首先,它指定另一个合约,称为 ProxyAdmin,作为代理合约的管理员。

展示代理、代理管理员和所有者在透明可升级代理中的关系的图

智能合约的地址永远不会改变,因此这与透明可升级代理将管理员地址存储在不可变变量中是兼容的。

第二,ProxyAdmin所有者是真正的管理员。ProxyAdmin 仅将调用从 owner 路由到 Proxy。真正的管理员调用 ProxyAdmin,然后 ProxyAdmin 调用透明代理。通过更改 ProxyAdmin 的所有者,我们可以更改谁有能力升级透明代理。

AdminProxy

以下是 OpenZeppelin AdminProxy 中的代码(已删除评论)。请注意,只有一个函数 upgradeAndCall(),它只能调用 Proxy 上的 upgradeToAndCall() 方法。

pragma solidity ^0.8.20;

import {ITransparentUpgradeableProxy} from "./TransparentUpgradeableProxy.sol";
import {Ownable} from "../../access/Ownable.sol";

contract ProxyAdmin is Ownable {
    string public constant UPGRADE_INTERFACE_VERSION = "5.0.0";

    constructor(address initialOwner) Ownable(initialOwner) {}

    function upgradeAndCall(
        ITransparentUpgradeableProxy proxy,
        address implementation,
        bytes memory data
    ) public payable virtual onlyOwner {
        proxy.upgradeToAndCall{value: msg.value}(implementation, data);
    }
}

有一种常见误解,认为透明代理的管理员无法使用合约,因为他们的调用被转发到升级逻辑。然而,AdminProxyowner 可以毫无问题地使用 Proxy,如下图所示。

实际上,我们稍后会看到,ProxyAdmin 有机制可以对代理进行任意调用,就像 upgradeToAndCall() 函数名称所暗示的那样。

透明可升级代理中所有者和用户可能的调用路径图

使代理不可升级

如果 owner 被更改为地址零,或另一个无法正常使用 upgradeAndCall()(或更改 owner)的智能合约,则透明可升级代理将不再可升级。此情况可能发生,例如,如果 AdminProxy 的所有者设置为不同的 AdminProxy 合约。

实施细节

OpenZeppelin 透明可升级代理通过三个合约实现标准:

  • Proxy.sol
  • ERC1967Proxy.sol(继承 Proxy.sol)
  • TransparentUpgradeableProxy.sol(继承 ERC1967Proxy.sol)

最上层合约:Proxy.sol

基础合约是 Proxy.sol。给定实现地址,它向实现发送 delegatecall。_implementation() 函数在 Proxy 中没有实现 — 它在其子合约 ERC1967Proxy 中被重写并实现,以返回相关存储槽。

abstract contract Proxy {
    function _delegate(address implementation) internal virtual {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())

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

    function _implementation() internal view virtual returns (address);

    function _fallback() internal virtual {
        _delegate(_implementation());
    }

    fallback() external payable virtual {
        _fallback();
    }
}

Proxy.sol 的子合约:ERC1967Proxy.sol

ERC1967Proxy.sol 继承自 Proxy.sol。这添加(并重写)了内部 _implementation() 函数,该函数返回存储在 ERC-1967 指定槽中的实现地址。这个合约的构造函数在指定的 ERC-1967 存储槽中存储实现。然而,透明可升级代理将不使用此函数 — 而是使用其自己的不可变变量。

pragma solidity ^0.8.20;

import {Proxy} from "../Proxy.sol";
import {ERC1967Utils} from "./ERC1967Utils.sol";

contract ERC1967Proxy is Proxy {

    constructor(address implementation, bytes memory _data) payable {
        ERC1967Utils.upgradeToAndCall(implementation, _data);
    }

    // 从 bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) 读取
    function _implementation() internal view virtual override returns (address) {
        return ERC1967Utils.getImplementation();
    }
}

ERC1967Proxy.sol 的子合约:TransparentUpgradeableProxy.sol

最后,TransparentUpgradeableProxy.sol 继承自 ERC1967Proxy.sol。在此合约的构造函数中,部署了 ProxyAdmin,并且不可变的管理员(合约中的第一个变量)在构造函数中设置为 ProxyAdmin 的地址。

contract TransparentUpgradeableProxy is ERC1967Proxy {
    address private immutable _admin;

    error ProxyDeniedAdminAccess();

    constructor(address _logic, address initialOwner, bytes memory _data) payable ERC1967Proxy(_logic, _data) {
        _admin = address(new ProxyAdmin(initialOwner));
        // 设置存储值并发出事件以兼容 ERC-1967
        ERC1967Utils.changeAdmin(_proxyAdmin());
    }

    function _proxyAdmin() internal view virtual returns (address) {
        return _admin;
    }

    function _fallback() internal virtual override {
        if (msg.sender == _proxyAdmin()) {
            if (msg.sig != ITransparentUpgradeableProxy.upgradeToAndCall.selector) {
                revert ProxyDeniedAdminAccess();
            } else {
                _dispatchUpgradeToAndCall();
            }
        } else {
            super._fallback();
        }
    }

    function _dispatchUpgradeToAndCall() private {
        (address newImplementation, bytes memory data) = abi.decode(msg.data[4:], (address, bytes));
        ERC1967Utils.upgradeToAndCall(newImplementation, data);
    }
}

让我们考虑 msg.sender_proxyAdmin 的情况。在这种情况下,调用被路由到 _dispatchUpgradeToAndCall(),但是 _fallback() 首先检查所提供的函数选择器是否是 upgradeToAndCall 的函数选择器。这里的“选择器”并不是“真实”的选择器,因为透明可升级代理没有公共函数,除 fallback。 但是,为了让 ProxyAdmin 能够进行 Solidity 接口调用 (高层调用),它需要接受来自 ProxyAdminupgradeToAndCall()ABI 编码的 calldata

回想一下,ProxyAdmin 正在对代理中的 upgradeToAndCall 进行一个接口调用,即使代理除了 fallback 之外没有其他公共函数(接下来显示 ProxyAdmin 代码):

管理员代理代码片段,highlight 了upgradeToAndCall函数

下面是一个视频,显示了所有三个代码块并排展示,以及继承链中的不同合约(ProxyERC1967ProxyTransparentUpgradeableProxy)之间是如何互动的:

https://img.learnblockchain.cn/2025/02/26/file.mp4

为什么选择 upgradeToAndCall() 而不是仅仅 upgradeTo()

在升级实现合约时,可以像 ProxyAdminmsg.sender 一样进行调用,并让交易被视为正常代理交互的 delegatecall 调用到实现。当然,在 fallback 内部并不会这样做,因为来自 ProxyAdmin 的调用会被路由到升级逻辑。

下面的代码来自于 ERC1967Utils.sol,它与 TransparentUpgradeableProxy 组合,使更新实现槽成为可能。该库提供了一个内部帮助函数,以更新保存实现地址的存储槽。

/** 
* @dev 在数据非空的情况下执行实现升级并附加设置调用。 
* 该函数仅在执行设置调用时可支付,否则将拒绝 `msg.value`,以避免合同中停留价值。 
* 
* 触发 {IERC1967-Upgraded} 事件。 
*/

function upgradeToAndCall(address newImplementation, bytes memory data) internal {
    _setImplementation(newImplementation);
    emit IERC1967.Upgraded(newImplementation);
    if (data.length > 0) {
        Address.functionDelegateCall(newImplementation, data);
    } else {
        _checkNonPayable();
    }
}

它只在 data.length > 0 时才会对实现合约进行 delegatecall。

upgradeToAndCall() 还会在与升级相同的交易中从 Proxy 对实现进行 delegatecall。这就像 ProxyAdmin 使用在 data 中指定的 calldata 调用代理,然后代理向实现发起一个 delegatecall。

通过这种方式,ProxyAdmin 能够向代理发出任意调用。

请注意,upgradeToAndCall 不要求升级的合约是不同的实现 — 可以将其“升级”到相同的实现。

这意味着 ProxyAdmin 合约可以通过 Proxy 对实现合约进行任意的 delegatecall — 但是从透明代理的角度来看,msg.senderProxyAdmin

这并不是一个“问题”,即 ProxyAdmin 可以使用合约 — ProxyAdmin 有能力完全更改实现 — ProxyAdmin 的所有者已经对 Proxy 拥有管理员控制权。

ProxyAdmin 在升级上的唯一限制是,他们不能升级到一个空合约(没有字节码的地址)。_setImplementation 函数检查新实现的 代码长度 是否大于零。

/**
 * @dev 在 ERC-1967 实现槽中存储新地址。
 */
function _setImplementation(address newImplementation) private {
    if (newImplementation.code.length == 0) {
        revert ERC1967InvalidImplementation(newImplementation);
    }
    StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = newImplementation;
}

透明可升级代理的总结

  • 透明可升级代理是一种设计模式,用于防止代理与实现之间的函数选择器冲突。
  • fallback 函数是透明可升级代理上的唯一公共函数。
  • 升级功能只能通过 fallback 函数由管理员调用。所有来自非管理员地址的调用将变为对代理的 delegatecall。
  • 透明可升级代理使用不可变变量存储管理员以节省Gas费用。为了符合 ERC-1967,它在 ERC-1967 指定的 admin 槽中存储管理员的地址,尽管它从不从该槽中读取。
  • 因为管理员无法更改,所以管理员被设置为一个被称为 AdminProxy 的智能合约。AdminProxy 公开一个单个函数 upgradeAndCall(),该函数只能由 AdminProxy 的所有者调用。AdminProxy 的所有者可以被更改。这种更改会影响谁可以更新透明可升级代理中的实现槽。

我们要感谢来自 OpenZeppelin 的 @ernestognw 审阅本文并提出有用的建议。

原始发布日期:6月4日

  • 原文链接: rareskills.io/post/trans...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/