本文介绍了 UUPS 代理模式,它将升级逻辑从代理合约转移到实现合约中,从而减少了 bytecode 大小、部署成本和复杂性。通过将升级功能放在实现合约中,每个实现都可以定义自己的升级规则。文章还通过 Foundry 演示了 UUPS 代理的部署和升级过程。
在之前的文章中,我们探讨了 透明代理 (EIP-1967) 模式,其中代理同时持有转发逻辑和升级控制平面。
这种设计是可行的,但也有一些负担:额外的管理合约、更多的字节码以及稍微更高的 gas 开销。
UUPS(通用可升级代理标准,EIP-1822) 采用了一种更精简的方法。
它保持代理作为一个 最小转发器,并将升级逻辑移到 实现合约 本身中。
你不是在代理上调用
upgradeTo,而是通过代理在实现上调用它。这使得代理可重用、更小且更易于推理,而每个实现都定义了自己的升级规则。
在这篇文章中,你将学到:
1. UUPS 与透明代理模式有何不同
2.
proxiableUUID和keccak256("PROXIABLE")存储槽的作用3. 如何使用 Foundry 端到端地部署和升级 UUPS 代理
最后,你将了解现代协议(以及 OpenZeppelin 的
UUPSUpgradeable合约)如何以更少的开销实现可升级性,以及这种简单性带来的权衡。
透明代理 很好,但它们有一个额外的负担:你必须同时维护代理合约和升级管理逻辑。UUPS (通用可升级代理标准) 颠覆了这种设计。
UUPS 代理不是让代理合约负责升级,而是将这种责任推到实现合约中。代理本身只是一个“哑转发器”,它委托所有调用。该实现包含一个特殊函数(通常称为 proxiableUUID 或 upgradeTo),该函数知道如何升级到新版本。这使得代理非常小且可重用,而每个实现都可以定义自己的规则来决定谁可以升级。
逻辑合约的地址存储在定义的存储位置 keccak256("PROXIABLE")=0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7。
主要区别:
UUPSUpgradeable 混合)。UUPSLogicContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
// 代理
contract Proxiable {
// 存储中的代码位置是 keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"
function updateCodeAddress(address newAddress) internal {
require(
bytes32(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7) == Proxiable(newAddress).proxiableUUID(),
"Not compatible" // 不兼容
);
assembly { // solium-disable-line
sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, newAddress)
}
}
function proxiableUUID() public pure returns (bytes32) {
return 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7;
}
}
// 控制只有所有者才能进行更改
contract Owned {
address owner;
function setOwner(address _owner) internal {
owner = _owner;
}
modifier onlyOwner() {
require(msg.sender == owner, "Only owner is allowed to perform this action"); // 只有所有者才能执行此操作
_;
}
}
contract LibraryLockDataLayout {
bool public initialized = false;
}
// 锁定机制
contract LibraryLock is LibraryLockDataLayout {
// 确保一旦部署 Logic Contract,任何人都无法操纵它
// PARITY WALLET HACK PREVENTION
modifier delegatedOnly() {
require(initialized == true, "The library is locked. No direct 'call' is allowed"); // 该库已锁定。不允许直接‘调用’
_;
}
function initialize() internal {
initialized = true;
}
}
contract ERC20DataLayout is LibraryLockDataLayout {
uint256 public totalSupply;
mapping(address=>uint256) public tokens;
}
contract MyToken is Owned, ERC20DataLayout, Proxiable, LibraryLock {
function constructor1(uint256 _initialSupply) public {
totalSupply = _initialSupply;
tokens[msg.sender] = _initialSupply;
initialize();
setOwner(msg.sender);
}
function updateCode(address newCode) public onlyOwner delegatedOnly {
updateCodeAddress(newCode);
}
function transfer(address to, uint256 amount) public delegatedOnly {
require(tokens[msg.sender] >= amount, "Not enough funds for transfer"); // 没有足够的资金用于转账
tokens[to] += amount;
tokens[msg.sender] -= amount;
}
}
注意: 这里添加了很多预防措施,主要的合约是继承了 Proxiable 的 MyToken。其他继承可以保护合约免受各种可能的攻击。
UUPSProxy.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
/*
* 非常简化的 UUPS 模式。
* - 代理存储状态并委托调用。
* - 实现持有升级逻辑。
*/
// ---------------- 代理 ----------------
contract UUPSProxy {
// 存储中的代码位置是 keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"
constructor(bytes memory constructData, address contractLogic) {
// 保存代码地址
assembly {
sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, contractLogic)
}
// 调用构造函数
(bool success, ) = contractLogic.delegatecall(constructData);
require(success, "Construction failed"); // 构造失败
}
// 此回退实际上将调用逻辑合约
// 因为每个带有数据字节的函数都会到达这里
fallback() external payable {
assembly {
// 加载逻辑合约地址
let contractLogic := sload(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7)
calldatacopy(0x0, 0x0, calldatasize())
// 使用数据字节调用逻辑合约
let success := delegatecall(sub(gas(), 10000), contractLogic, 0x0, calldatasize(), 0, 0)
let retSz := returndatasize()
returndatacopy(0, 0, retSz)
switch success
case 0 {
revert(0, retSz)
}
default {
return(0, retSz)
}
}
}
}
让我们启动节点:
anvil
并从不同的终端部署两个合约
// 部署合约逻辑
forge create src/UUPSLogicContract.sol:MyToken --rpc-url localhost:8545 --private-key <YOUR-ANVIL-PRIVATE-KEY> --broadcast
// 预期输出,类似于:
// [⠊] Compiling...
// [⠒] Compiling 1 files with Solc 0.8.30
// [⠆] Solc 0.8.30 finished in 69.50ms
// Compiler run successful!
// Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
// Transaction hash: 0xfd7f81e7a54dba55a1a6399e465ed9ff67dc0d95a3f2fbbf85542fd7b0ffdf81
// 部署 UUPS 代理
INIT_DATA=$(cast calldata "constructor1(uint256)" 1000000)
forge create src/UUPSProxy.sol:UUPSProxy --rpc-url localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast --constructor-args "$INIT_DATA" 0x5FbDB2315678afecb367f032d93F642f64180aa3
// 预期输出,类似于:
// [⠊] Compiling...
// No files changed, compilation skipped
// Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// Deployed to: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
// Transaction hash: 0x60c04bf75c647a9c5bcd73155c8906675247ccf4f25fab20bf76e1b5a4ef129e
现在让我们通过代理调用 MyToken 合约上自动生成的 getter:
// 注意 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 是代理地址
cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 "totalSupply()(uint256)" --rpc-url http://localhost:8545
UUPS 代理将可升级性简化为最简单的形式。
代理只知道如何转发调用。实现 通过其自身的 updateCode(或 upgradeTo)函数处理升级,并通过访问控制进行保护。
这种模式减少了字节码大小、部署成本和复杂性,但也给实现合约带来了更大的责任。升级逻辑中的一个错误可能会使系统崩溃,这就是为什么生产框架(如 OpenZeppelin 的 UUPSUpgradeable)用保护措施和 ERC-1967 插槽标准来包装它。
理解 UUPS 可以让你更深入地了解真实协议如何平衡 不变性和灵活性,以及单个 delegatecall 背后隐藏了多少力量(和风险)。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!