返回 SC 笔记
SC Day 46

Solidity - 代理模式深入 (Transparent Proxy / UUPS / Beacon + 存储冲突)

### 1. 为什么需要可升级合约?

2026-05-16
第二阶段:框架实战 (46-48)
ProxyUpgradeableUUPSTransparentProxyBeaconEIP1967StorageCollisiondelegatecall

日期: 2026-05-16 方向: Solidity 阶段: 第二阶段:框架实战 (46-48) 标签: #Proxy #Upgradeable #UUPS #TransparentProxy #Beacon #EIP1967 #StorageCollision #delegatecall


今日目标

类型内容
学习理解三种代理模式(Transparent / UUPS / Beacon)的原理、区别与适用场景
实操使用 OpenZeppelin 编写 UUPS 可升级合约 V1→V2,用 Foundry 测试完整升级流程
产出代理模式对比表、存储冲突案例分析、Foundry 测试代码

核心概念

1. 为什么需要可升级合约?

以太坊合约部署后代码不可变(immutable)。这在安全性上是优势,但在实际开发中带来严重问题:

  • Bug 修复:发现漏洞后无法直接修改代码
  • 功能迭代:业务需求变化需要添加新逻辑
  • 参数调整:某些参数硬编码在合约中需要修改

解决方案是代理模式(Proxy Pattern):将"存储"和"逻辑"分离到不同合约中。

2. delegatecall — 代理模式的基石

delegatecall 是 EVM 提供的一种特殊调用方式。与普通 call 不同,delegatecall调用者的存储上下文中执行被调用合约的代码。

普通 call:
┌─────────────────┐         ┌─────────────────┐
│   Proxy 合约     │  call   │   Logic 合约     │
│                  │ ──────> │                  │
│   storage: 自己  │         │   storage: 自己   │
│   msg.sender: EOA│         │   msg.sender: Proxy│
└─────────────────┘         └─────────────────┘

delegatecall:
┌─────────────────┐  delegatecall  ┌─────────────────┐
│   Proxy 合约     │ ────────────> │   Logic 合约     │
│                  │               │                  │
│   storage: Proxy │               │   读写 Proxy 的   │
│   msg.sender: EOA│               │   storage!       │
└─────────────────┘               └─────────────────┘

关键特性:

特性calldelegatecall
代码执行上下文被调用合约调用者合约
存储读写被调用合约的 storage调用者的 storage
msg.sender调用者合约地址原始发起人 (EOA)
msg.value可传递可传递

3. 最简代理合约结构

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

contract MinimalProxy {
    // 逻辑合约地址
    address public implementation;

    // 管理员(可以升级)
    address public admin;

    constructor(address _implementation) {
        implementation = _implementation;
        admin = msg.sender;
    }

    // 升级函数
    function upgrade(address newImplementation) external {
        require(msg.sender == admin, "Not admin");
        implementation = newImplementation;
    }

    // fallback: 将所有未知调用转发给逻辑合约
    fallback() external payable {
        address impl = implementation;
        assembly {
            // 复制 calldata
            calldatacopy(0, 0, calldatasize())
            // delegatecall 到逻辑合约
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            // 复制返回值
            returndatacopy(0, 0, returndatasize())
            // 根据结果 return 或 revert
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    receive() external payable {}
}

问题出现了:上面这个代理合约有一个严重缺陷 —— 函数选择器冲突(Function Selector Clash)

如果逻辑合约里恰好也有一个 upgrade(address) 函数,用户调用时到底执行的是 Proxy 自己的 upgrade 还是逻辑合约的?答案是 Proxy 自己的,因为 Solidity 先匹配自身定义的函数,匹配不到才走 fallback

更隐蔽的情况:两个不同签名的函数可能产生相同的 4 字节选择器(概率极低但理论存在),这会导致不可预期的行为。

4. EIP-1967: 标准存储槽

为了避免代理合约的管理变量(implementation 地址、admin 地址)与逻辑合约的存储发生冲突,EIP-1967 定义了标准存储槽位:

// Implementation 地址存储位置
// bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
bytes32 constant IMPLEMENTATION_SLOT =
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

// Admin 地址存储位置
// bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
bytes32 constant ADMIN_SLOT =
    0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

// Beacon 地址存储位置
// bytes32(uint256(keccak256("eip1967.proxy.beacon")) - 1)
bytes32 constant BEACON_SLOT =
    0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50;

为什么要减 1?这是为了确保该槽位不会是某个 mapping 或 array 的哈希起始位置,进一步降低冲突概率。Solidity 的 mapping 和 dynamic array 使用 keccak256(key . slot) 来计算存储位置,减 1 后的值无法通过正常方式被映射到。

5. 三种代理模式详解

5.1 Transparent Proxy(透明代理)

核心思路:通过调用者身份来区分行为。

  • Admin 调用 → 执行 Proxy 自身的管理函数(升级、更换 admin)
  • 非 Admin 调用 → 一律通过 delegatecall 转发给逻辑合约
用户调用 proxy.transfer():
  → msg.sender != admin → delegatecall to implementation → 执行 transfer()

Admin 调用 proxy.upgrade(newImpl):
  → msg.sender == admin → 执行 proxy 自身的 upgrade()

Admin 调用 proxy.transfer():
  → msg.sender == admin → 执行 proxy 自身的 transfer()?
  → 不!admin 的调用永远不会被转发。admin 只能调管理函数。

实际实现中,OpenZeppelin 使用独立的 ProxyAdmin 合约来管理

┌───────────┐      ┌──────────────┐      ┌──────────────────┐
│    EOA    │      │  ProxyAdmin  │      │ TransparentProxy │
│  (Owner)  │─────>│   合约        │─────>│    合约           │
└───────────┘      └──────────────┘      └──────────────────┘
                     只有它能调                  │
                     upgrade()            delegatecall
                                               │
                                    ┌──────────────────┐
                                    │ Implementation   │
                                    │   合约 V1/V2      │
                                    └──────────────────┘

优点

  • 完全消除函数选择器冲突
  • 逻辑合约无需关心升级机制
  • 升级逻辑在 Proxy 端,逻辑合约保持简洁

缺点

  • 每次调用多一次 sload:需要读取 admin 地址来判断调用者身份
  • Gas 更高:额外的 admin 检查约增加 2100 gas(冷读取 storage slot)
  • 需要独立的 ProxyAdmin 合约:部署成本更高

5.2 UUPS(Universal Upgradeable Proxy Standard, EIP-1822)

核心思路:将升级逻辑放在 Implementation 合约中,而非 Proxy 中。

┌────────────────────┐
│   UUPS Proxy       │
│                    │
│   极简: 只有       │
│   fallback()       │
│   + EIP-1967 slot  │
│                    │
│   没有 upgrade()!  │
└─────────┬──────────┘
          │ delegatecall
          │
┌─────────▼──────────┐
│  Implementation V1 │
│                    │
│  业务逻辑           │
│  + upgradeToAndCall()│  ← 升级函数在这里!
│  + _authorizeUpgrade()│
└────────────────────┘

为什么升级函数可以在 Implementation 中?

因为 delegatecall 让 Implementation 的代码在 Proxy 的 storage 上下文执行。当 Implementation 中的 upgradeToAndCall() 写入新的 implementation 地址时,实际上修改的是 Proxy 的 EIP-1967 存储槽。

优点

  • Proxy 更轻量:更低的部署成本
  • 无 admin 检查:每次调用不用检查调用者身份,Gas 更省
  • 更灵活:可以在升级时移除升级能力(不可逆锁定合约)

缺点

  • Implementation 必须包含升级逻辑:如果部署了一个没有 upgradeToAndCall 的 Implementation,合约将永远无法升级
  • 开发者责任更大:忘记继承 UUPSUpgradeable 就永久锁死

5.3 Beacon Proxy(信标代理)

核心思路:多个 Proxy 共享同一个 Beacon 合约来获取 Implementation 地址。升级 Beacon 即可同时升级所有 Proxy。

┌──────────┐  ┌──────────┐  ┌──────────┐
│ Proxy A  │  │ Proxy B  │  │ Proxy C  │
└────┬─────┘  └────┬─────┘  └────┬─────┘
     │             │             │
     └─────────────┼─────────────┘
                   │
            ┌──────▼──────┐
            │   Beacon    │  ← 存储 implementation 地址
            │   合约       │
            └──────┬──────┘
                   │
            ┌──────▼──────┐
            │   Impl V1   │  ← 所有 Proxy 共享
            └─────────────┘

升级时: Beacon.upgrade(ImplV2) → 所有 Proxy 同时升级

使用场景

  • 同一逻辑的大量实例(如每个用户一个 Vault 合约)
  • 工厂模式创建的合约集合
  • 需要原子化批量升级

优点

  • 一次升级影响所有实例
  • 每个 Proxy 部署成本极低

缺点

  • 每次调用多一次外部调用(读取 Beacon 获取地址)
  • 无法对单个实例独立升级

6. 三种模式对比

维度Transparent ProxyUUPSBeacon
升级逻辑位置Proxy 合约Implementation 合约Beacon 合约
Proxy 复杂度
每次调用开销+sload (admin 检查)无额外开销+external call (读 Beacon)
部署成本 (Proxy)较高较低最低
批量升级逐个升级逐个升级一次升级所有
安全风险低(Proxy 控制升级)中(忘记 upgrade 函数则锁死)
适用场景单例合约、高安全要求大多数场景、Gas 敏感大量同质化实例
OpenZeppelin 推荐曾经默认,现已转向 UUPS当前默认推荐工厂模式

7. 存储冲突(Storage Collision)— 最危险的升级陷阱

什么是存储冲突?

EVM 的 storage 按 slot 编号(0, 1, 2, ...)存储。delegatecall 让逻辑合约的代码读写 Proxy 的 storage。如果 Proxy 和 Implementation 的变量声明顺序不一致,就会发生存储冲突。

// Proxy 合约
contract Proxy {
    address public implementation; // slot 0
    address public admin;          // slot 1
}

// Implementation V1
contract LogicV1 {
    uint256 public value;   // slot 0 — 与 implementation 地址冲突!
    address public owner;   // slot 1 — 与 admin 地址冲突!
}

当 LogicV1 读取 value(slot 0)时,实际读到的是 Proxy 的 implementation 地址!这就是为什么 EIP-1967 将管理变量放在随机的高位 slot。

升级时的存储冲突

更常见的问题发生在合约升级时:

// V1
contract LogicV1 {
    uint256 public value;    // slot 0
    address public owner;    // slot 1
}

// V2 — 错误示范!
contract LogicV2 {
    address public owner;    // slot 0 — 原来是 value!
    uint256 public value;    // slot 1 — 原来是 owner!
    uint256 public newField; // slot 2 — OK
}

正确做法:V2 只能在末尾添加新变量,不能改变已有变量的顺序。

// V2 — 正确示范
contract LogicV2 {
    uint256 public value;    // slot 0 — 保持不变
    address public owner;    // slot 1 — 保持不变
    uint256 public newField; // slot 2 — 新增在末尾
}

继承中的存储布局

继承链中的存储按照 C3 线性化顺序排列:

contract Base {
    uint256 public a; // slot 0
    uint256 public b; // slot 1
}

contract Child is Base {
    uint256 public c; // slot 2
}

升级时如果在中间插入父合约或修改父合约变量,同样会导致冲突。

OpenZeppelin 的 storage gap 模式

contract BaseV1 {
    uint256 public value;

    // 预留 49 个 slot,总共 Base 占 50 个 slot
    uint256[49] private __gap;
}

contract BaseV2 {
    uint256 public value;
    uint256 public newValue; // 使用一个 gap slot

    // gap 减少为 48
    uint256[48] private __gap;
}

代码实战

UUPS 可升级合约 V1 → V2 (OpenZeppelin)

Implementation V1

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

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

/// @title CounterV1 — 可升级的计数器合约(第一版)
contract CounterV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 public count;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        // 禁止直接在 implementation 上调用 initialize
        _disableInitializers();
    }

    /// @notice 初始化函数,替代 constructor
    function initialize(address initialOwner) public initializer {
        __Ownable_init(initialOwner);
        __UUPSUpgradeable_init();
        count = 0;
    }

    /// @notice 计数器加 1
    function increment() external {
        count += 1;
    }

    /// @notice 获取当前版本号
    function version() external pure returns (string memory) {
        return "1.0.0";
    }

    /// @notice UUPS 升级授权检查 — 只有 owner 可以升级
    function _authorizeUpgrade(address newImplementation)
        internal
        override
        onlyOwner
    {}
}

Implementation V2

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

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

/// @title CounterV2 — 计数器合约升级版(新增 decrement 和 step 功能)
contract CounterV2 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    // ======== 保持 V1 存储布局 ========
    uint256 public count;         // slot 0 — 与 V1 一致

    // ======== V2 新增变量 ========
    uint256 public step;          // slot 1 — 新增在末尾
    uint256 public lastModified;  // slot 2 — 新增在末尾

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

    /// @notice V2 升级后的重新初始化(reinitializer(2) 确保只执行一次)
    function initializeV2(uint256 _step) public reinitializer(2) {
        step = _step;
        lastModified = block.timestamp;
    }

    /// @notice 按 step 增加
    function increment() external {
        count += step;
        lastModified = block.timestamp;
    }

    /// @notice 按 step 减少(V2 新增)
    function decrement() external {
        require(count >= step, "Counter: underflow");
        count -= step;
        lastModified = block.timestamp;
    }

    /// @notice 设置步长(仅 owner)
    function setStep(uint256 _step) external onlyOwner {
        require(_step > 0, "Counter: step must be > 0");
        step = _step;
    }

    /// @notice 重置计数器(仅 owner)
    function reset() external onlyOwner {
        count = 0;
        lastModified = block.timestamp;
    }

    function version() external pure returns (string memory) {
        return "2.0.0";
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        override
        onlyOwner
    {}
}

Foundry 测试

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

import "forge-std/Test.sol";
import "../src/CounterV1.sol";
import "../src/CounterV2.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract CounterUpgradeTest is Test {
    CounterV1 implementationV1;
    CounterV2 implementationV2;
    ERC1967Proxy proxy;

    address owner = address(0xABCD);
    address user = address(0x1234);

    function setUp() public {
        // 1. 部署 V1 implementation
        implementationV1 = new CounterV1();

        // 2. 部署 Proxy,指向 V1,并调用 initialize
        bytes memory initData = abi.encodeWithSelector(
            CounterV1.initialize.selector,
            owner
        );
        proxy = new ERC1967Proxy(address(implementationV1), initData);

        // 3. 部署 V2 implementation(稍后升级用)
        implementationV2 = new CounterV2();
    }

    // ====== V1 测试 ======

    function test_V1_InitialState() public view {
        CounterV1 counter = CounterV1(address(proxy));
        assertEq(counter.count(), 0);
        assertEq(counter.version(), "1.0.0");
        assertEq(counter.owner(), owner);
    }

    function test_V1_Increment() public {
        CounterV1 counter = CounterV1(address(proxy));
        counter.increment();
        counter.increment();
        counter.increment();
        assertEq(counter.count(), 3);
    }

    function test_V1_CannotReinitialize() public {
        CounterV1 counter = CounterV1(address(proxy));
        vm.expectRevert();
        counter.initialize(user);
    }

    // ====== 升级测试 ======

    function test_UpgradeToV2() public {
        CounterV1 counterV1 = CounterV1(address(proxy));

        // 先在 V1 上操作
        counterV1.increment();
        counterV1.increment();
        assertEq(counterV1.count(), 2);

        // 升级到 V2(只有 owner 可以)
        vm.prank(owner);
        CounterV1(address(proxy)).upgradeToAndCall(
            address(implementationV2),
            abi.encodeWithSelector(CounterV2.initializeV2.selector, 5)
        );

        // 验证升级成功
        CounterV2 counterV2 = CounterV2(address(proxy));
        assertEq(counterV2.version(), "2.0.0");

        // 验证状态保留!count 应该还是 2
        assertEq(counterV2.count(), 2);

        // 验证新功能
        assertEq(counterV2.step(), 5);
    }

    function test_V2_IncrementByStep() public {
        // 升级到 V2
        vm.prank(owner);
        CounterV1(address(proxy)).upgradeToAndCall(
            address(implementationV2),
            abi.encodeWithSelector(CounterV2.initializeV2.selector, 10)
        );

        CounterV2 counter = CounterV2(address(proxy));
        counter.increment();
        assertEq(counter.count(), 10); // 按 step=10 增加

        counter.increment();
        assertEq(counter.count(), 20);
    }

    function test_V2_Decrement() public {
        vm.prank(owner);
        CounterV1(address(proxy)).upgradeToAndCall(
            address(implementationV2),
            abi.encodeWithSelector(CounterV2.initializeV2.selector, 3)
        );

        CounterV2 counter = CounterV2(address(proxy));
        counter.increment(); // count = 3
        counter.increment(); // count = 6
        counter.decrement(); // count = 3
        assertEq(counter.count(), 3);
    }

    function test_V2_DecrementUnderflow() public {
        vm.prank(owner);
        CounterV1(address(proxy)).upgradeToAndCall(
            address(implementationV2),
            abi.encodeWithSelector(CounterV2.initializeV2.selector, 5)
        );

        CounterV2 counter = CounterV2(address(proxy));
        // count = 0, step = 5, decrement 应该 revert
        vm.expectRevert("Counter: underflow");
        counter.decrement();
    }

    function test_OnlyOwnerCanUpgrade() public {
        // 非 owner 尝试升级应该失败
        vm.prank(user);
        vm.expectRevert();
        CounterV1(address(proxy)).upgradeToAndCall(
            address(implementationV2),
            abi.encodeWithSelector(CounterV2.initializeV2.selector, 1)
        );
    }

    function test_CannotInitializeImplementationDirectly() public {
        // 直接在 implementation 合约上调用 initialize 应该失败
        vm.expectRevert();
        implementationV1.initialize(owner);

        vm.expectRevert();
        implementationV2.initializeV2(5);
    }
}

关键要点总结

  1. delegatecall 是所有代理模式的基石:逻辑合约的代码在 Proxy 的存储上下文中执行
  2. EIP-1967 标准化了管理变量的存储位置:用 keccak256 哈希减 1 计算 slot,避免与逻辑合约变量冲突
  3. Transparent Proxy 用身份区分行为:admin 的调用执行管理函数,其他人的调用转发到 implementation
  4. UUPS 把升级逻辑放在 implementation 中:Proxy 极简、Gas 更省,但忘记 upgrade 函数会永久锁死
  5. Beacon Proxy 实现一对多升级:适合工厂模式创建的大量同质化合约实例
  6. 存储布局是升级合约的生命线:V2 绝不能改变 V1 已有变量的顺序,只能在末尾追加
  7. _disableInitializers() 在 constructor 中调用:防止任何人直接在 implementation 合约上调用 initialize
  8. reinitializer(n) 用于升级后初始化:每个版本号只能执行一次,防止重复初始化

常见误区

误区 1: "在 V2 中间插入变量是安全的"

// V1
contract V1 {
    uint256 public a; // slot 0
    uint256 public b; // slot 1
}

// V2 — 错误!
contract V2 {
    uint256 public a;      // slot 0 ✓
    uint256 public newVar; // slot 1 — 覆盖了 b 的数据!
    uint256 public b;      // slot 2 — 读到的是空值
}

正确做法:永远在末尾追加。

误区 2: "Implementation 合约不需要 constructor 保护"

如果不在 implementation 的 constructor 中调用 _disableInitializers(),攻击者可以直接在 implementation 合约上调用 initialize(),获取 owner 权限。虽然这不直接影响 Proxy,但可能在某些组合场景下被利用(比如 implementation 持有 ETH 或有 selfdestruct)。

误区 3: "UUPS 比 Transparent Proxy 不安全"

两者的安全模型不同但不是一个更安全。UUPS 的风险在于开发者可能忘记升级函数,但这可以通过 OpenZeppelin 的工具和测试来防范。实际上 OpenZeppelin 从 v5 开始默认推荐 UUPS。

误区 4: "可以在升级时改变变量类型"

// V1: slot 0 是 uint256
uint256 public value; // 存储值 42

// V2: slot 0 改为 address — 灾难!
address public owner; // 读取 slot 0 得到地址 0x000...002a (42 的十六进制)

类型改变会导致数据解释方式完全不同。


面试关联

Q1: "请解释 Transparent Proxy 和 UUPS 的区别,你会推荐哪个?"

回答框架

  • 升级逻辑位置:Transparent 在 Proxy,UUPS 在 Implementation
  • Gas 效率:UUPS 更优(无 admin 检查)
  • 部署成本:UUPS 的 Proxy 更轻量
  • 安全考量:Transparent 不会忘记升级函数,UUPS 需要开发者纪律
  • 推荐:大多数情况推荐 UUPS(OpenZeppelin 当前默认),批量实例用 Beacon

Q2: "什么是存储冲突?如何避免?"

关键点

  • delegatecall 使用 Proxy 的 storage,变量按 slot 位置对应
  • EIP-1967 把管理变量放在高位随机 slot
  • 升级时只能追加变量,不能修改、删除或重排已有变量
  • 使用 __gap 预留空间应对继承升级
  • 工具:OpenZeppelin Upgrades Plugins 会自动检查存储兼容性

Q3: "如果部署了一个没有 upgrade 函数的 UUPS implementation,会怎样?"

合约将永远无法升级。因为 UUPS 的升级函数在 implementation 中,如果新部署的 implementation 缺失该函数,Proxy 就无法接收升级调用。这是 UUPS 的核心风险,也是为什么必须使用 OpenZeppelin 的 UUPSUpgradeable 基类和自动化检查工具。


参考资源