代理和可升级性——UUPS 代理 (EIP-1822)

本文介绍了 UUPS 代理模式,它将升级逻辑从代理合约转移到实现合约中,从而减少了 bytecode 大小、部署成本和复杂性。通过将升级功能放在实现合约中,每个实现都可以定义自己的升级规则。文章还通过 Foundry 演示了 UUPS 代理的部署和升级过程。

代理和可升级性——UUPS 代理 (EIP-1822)

在之前的文章中,我们探讨了 透明代理 (EIP-1967) 模式,其中代理同时持有转发逻辑和升级控制平面。

这种设计是可行的,但也有一些负担:额外的管理合约、更多的字节码以及稍微更高的 gas 开销。

UUPS(通用可升级代理标准,EIP-1822) 采用了一种更精简的方法。

它保持代理作为一个 最小转发器,并将升级逻辑移到 实现合约 本身中。

你不是在代理上调用 upgradeTo,而是通过代理在实现上调用它。

这使得代理可重用、更小且更易于推理,而每个实现都定义了自己的升级规则。

在这篇文章中,你将学到:

1. UUPS 与透明代理模式有何不同

2. proxiableUUIDkeccak256("PROXIABLE") 存储槽的作用

3. 如何使用 Foundry 端到端地部署和升级 UUPS 代理

最后,你将了解现代协议(以及 OpenZeppelin 的 UUPSUpgradeable 合约)如何以更少的开销实现可升级性,以及这种简单性带来的权衡。

UUPS 代理 ( EIP-1822)

透明代理 很好,但它们有一个额外的负担:你必须同时维护代理合约和升级管理逻辑。UUPS (通用可升级代理标准) 颠覆了这种设计。

UUPS 代理不是让代理合约负责升级,而是将这种责任推到实现合约中。代理本身只是一个“哑转发器”,它委托所有调用。该实现包含一个特殊函数(通常称为 proxiableUUIDupgradeTo),该函数知道如何升级到新版本。这使得代理非常小且可重用,而每个实现都可以定义自己的规则来决定谁可以升级。

逻辑合约的地址存储在定义的存储位置 keccak256("PROXIABLE")=0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7

主要区别:

  • 代理更精简:内部没有升级代码。
  • 每个实现合约必须包含一个升级函数(通常是 OpenZeppelin 的 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Andrey Obruchkov
Andrey Obruchkov
江湖只有他的大名,没有他的介绍。