文章详细解释了透明可升级代理模式,该模式旨在升级代理时消除函数选择器冲突的可能性。文章介绍了代理合约的基本需求、函数选择器冲突问题及其解决方案,并通过代码示例和图表深入探讨了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
(或代理中的任何其他公共函数)。
因此,如果在代理中声明了一个公共函数,则可能发生两种类型的函数选择器冲突:
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
的所有者,我们可以更改谁有能力升级透明代理。
以下是 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);
}
}
有一种常见误解,认为透明代理的管理员无法使用合约,因为他们的调用被转发到升级逻辑。然而,AdminProxy
的 owner
可以毫无问题地使用 Proxy
,如下图所示。
实际上,我们稍后会看到,ProxyAdmin
有机制可以对代理进行任意调用,就像 upgradeToAndCall()
函数名称所暗示的那样。
如果 owner
被更改为地址零,或另一个无法正常使用 upgradeAndCall()
(或更改 owner
)的智能合约,则透明可升级代理将不再可升级。此情况可能发生,例如,如果 AdminProxy 的所有者设置为不同的 AdminProxy 合约。
OpenZeppelin 透明可升级代理通过三个合约实现标准:
基础合约是 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();
}
}
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();
}
}
最后,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 接口调用 (高层调用),它需要接受来自 ProxyAdmin
的 upgradeToAndCall()
的 ABI 编码的 calldata。
回想一下,ProxyAdmin
正在对代理中的 upgradeToAndCall
进行一个接口调用,即使代理除了 fallback 之外没有其他公共函数(接下来显示 ProxyAdmin
代码):
下面是一个视频,显示了所有三个代码块并排展示,以及继承链中的不同合约(Proxy
、ERC1967Proxy
和 TransparentUpgradeableProxy
)之间是如何互动的:
https://img.learnblockchain.cn/2025/02/26/file.mp4
upgradeToAndCall()
而不是仅仅 upgradeTo()
?在升级实现合约时,可以像 ProxyAdmin
是 msg.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.sender
是 ProxyAdmin
。
这并不是一个“问题”,即 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;
}
admin
槽中存储管理员的地址,尽管它从不从该槽中读取。AdminProxy
的智能合约。AdminProxy
公开一个单个函数 upgradeAndCall()
,该函数只能由 AdminProxy
的所有者调用。AdminProxy
的所有者可以被更改。这种更改会影响谁可以更新透明可升级代理中的实现槽。我们要感谢来自 OpenZeppelin 的 @ernestognw 审阅本文并提出有用的建议。
原始发布日期:6月4日
- 原文链接: rareskills.io/post/trans...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!