透明可升级代理(Transparent Proxy)详解

理解函数选择器冲突问题、用 msg.sender 路由解决冲突、ProxyAdmin 与 upgradeToAndCall

6 分钟阅读
透明可升级代理(Transparent Proxy)详解

透明可升级代理(Transparent Proxy)详解

目录


一、核心问题:函数选择器冲突

传统代理如果在自己身上加公开的升级函数(如 changeImplementation()),会引发两类冲突:

  1. 精确同名冲突:如果实现合约里也有同名函数,它就永远调不到——代理的版本会在 delegatecall 之前就拦截这个调用;
  2. 随机选择器冲突:不同名的函数也可能算出相同的 4 字节选择器。可能组合约 42.9 亿种,单对碰撞概率 1/42.9 亿看似很低,但实践中不可忽略。例如 clash550254402()proxyAdmin() 就共享同一个选择器。

只要代理上有任何具名公开函数,就有和实现函数撞选择器、导致实现的某个函数被永久屏蔽的风险。

二、透明代理的解法:除 fallback 外没有公开函数

透明代理用一个优雅思路彻底消除冲突:代理上除了 fallback,不暴露任何公开函数

升级逻辑不做成公开函数,而是靠 msg.sender 判断

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

代理没有任何具名函数,自然就没有任何选择器能和实现冲突。所有调用都进 fallback,再按调用者身份分流。

三、管理员难题与 ProxyAdmin 合约

新问题:如果 admin 存成 immutable 变量(省 Gas),它就改不了了;但实际中常常需要更换管理员。

解法:引入一个独立的 ProxyAdmin 合约:

  • ProxyAdmin 是透明代理那个不可变的 admin
  • ProxyAdmin 的 owner 才是真正的管理者;
  • 换 owner = 换谁能升级代理,于是”admin 可变”和”immutable 优化”两全其美。

ProxyAdmin 只暴露一个函数:

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

四、ERC-1967 兼容

透明代理把 admin 存成 immutable 变量(读取省约 2,100 gas),但仍然兼容 ERC-1967

  • 它在 ERC-1967 标准 admin 插槽 keccak256('eip1967.proxy.admin') - 1 里也写入 admin 地址;
  • 但实际运行从不读这个插槽(以 immutable 为准);
  • 写它纯粹是为了给区块浏览器发信号:我是个代理。

五、升级机制:upgradeToAndCall

升级函数接收两个参数:

  1. newImplementation:新实现地址;
  2. data:可选的初始化 calldata。

它能在一笔交易里做三件事:

  • 更新实现地址插槽;
  • 如果 data 非空,delegatecall 到新实现执行初始化逻辑(此时从实现视角 msg.sender 是 ProxyAdmin);
  • 全部原子完成。

这样升级后能立刻初始化新实现,无需第二笔交易。该函数会拒绝升级到空地址(无字节码),确保实现始终指向有效代码。

六、OpenZeppelin 的三层继承结构

合约职责
Proxy.sol(基类)提供 _delegate() 汇编函数执行 delegatecall,以及 fallback()_implementation() 是抽象的,由子类实现
ERC1967Proxy.sol实现 _implementation() 从 ERC-1967 插槽读地址;构造函数用 ERC1967Utils.upgradeToAndCall() 存初始实现
TransparentUpgradeableProxy.sol把 ProxyAdmin 地址存成 immutable;重写 _fallback() 检查 msg.sender == admin

透明代理的 _fallback() 逻辑:

  • msg.sender == admin:校验选择器必须是 upgradeToAndCall(),然后分派到 _dispatchUpgradeToAndCall()
  • 若非 admin:走父类 _fallback(),正常 delegatecall。

注意即使代理没有公开函数,admin 路径里仍需解码并校验 ProxyAdmin 传来的 calldata,确保管理员只能调用升级,不能干别的。

七、用户路径 vs 管理员路径

调用者路径
普通用户全部经 fallback → delegatecall 到实现,访问不到任何升级函数
ProxyAdmin(admin)经 fallback 的 admin 分支,只允许 upgradeToAndCall 选择器
ProxyAdmin 的 owner(真正管理员)调 ProxyAdmin 的 upgradeAndCall,再由它调代理的升级,获得完整升级权

这种分流的关键好处:管理员永远不会”不小心”以管理员身份调到实现的某个普通函数(因为 admin 路径只认升级选择器),从根上避免了管理员和用户调用语义混淆。

八、让代理变成不可升级

把 ProxyAdmin 的 owner 设为零地址、或设为一个无法发起升级的合约,代理就永久不可升级。一旦所有权被弃置或转给无功能合约,再也无法升级——这是项目”去中心化交权”的常见手段。

九、与 UUPS 的对比

Transparent ProxyUUPS
升级逻辑放哪在代理(fallback 里判断)实现合约
额外合约需要 ProxyAdmin不需要
代理部署成本较高(fallback 多一次 sender 判断逻辑、需 ProxyAdmin)较低
风险较稳健若新实现忘了带升级函数,会永久锁死无法再升级

简言之:Transparent 把升级权放代理、更省心但更重;UUPS 把升级权放实现、更轻但要求每个新实现都记得保留升级入口。

十、总结

  • 选择器冲突是传统代理的核心痛点(精确同名 + 随机碰撞);
  • 透明代理”代理上零公开函数 + 按 msg.sender 路由”彻底解决;
  • ProxyAdmin 让 immutable admin 也能”换人”;
  • 兼容 ERC-1967(写插槽给浏览器看,运行用 immutable);
  • upgradeToAndCall 一笔交易完成升级 + 初始化;
  • 对比 UUPS:透明更重更稳,UUPS 更轻但有锁死风险。

十一、动手练习项目:透明代理 TransparentVault

项目目标

从零实现透明代理三件套(Proxy + ProxyAdmin + 实现),亲手复现选择器冲突 bug 并用”按 sender 路由”修复,完成 V1→V2 升级 + 初始化。部署到 Sepolia。

合约要求

1. VaultV1.sol / VaultV2.sol(实现合约,ERC-7201 或对齐布局存储)

  • V1:deposit()balanceOf()initialize(address owner)(用 initializer 防重复初始化);
  • V2:保持布局兼容,新增 withdraw() 和一个 version() 返回 2。

2. TransparentVaultProxy.sol

  • admin 存成 immutable(构造传入 ProxyAdmin 地址)+ 同时写 ERC-1967 admin 插槽;
  • 初始 implementation 写 ERC-1967 implementation 插槽;
  • _fallback()
    • msg.sender == admin → 要求 calldata 选择器为 upgradeToAndCall(address,bytes),否则 revert ProxyDeniedAdminAccess();执行更新插槽 + 可选 delegatecall 初始化;
    • 否则 → 汇编 delegatecall 到当前 implementation,冒泡返回/revert。

3. ProxyAdmin.sol

  • Ownable,唯一函数 upgradeAndCall(proxy, impl, data) onlyOwner 转发到代理。

测试要求(Foundry)

  1. test_SelectorClash_Demonstrated:先写一个”代理带公开 upgradeTo 函数”的反面版本,让实现也有同选择器函数,断言实现函数被屏蔽——复现冲突;
  2. test_TransparentRouting:普通用户调 deposit() 正常进实现;管理员调 deposit() 应 revert(admin 路径只允许升级选择器),证明路由生效;
  3. test_UserCannotUpgrade:普通用户构造 upgradeToAndCall calldata 调代理,应当作普通调用 delegatecall 到实现(实现无此函数 → revert),无法升级;
  4. test_Upgrade_V1_to_V2_WithInit:owner 经 ProxyAdmin 升级到 V2 并在同笔交易 delegatecall 初始化,断言 version()==2 且旧余额保留;
  5. test_AdminSlot_ERC1967vm.load 读 ERC-1967 admin 插槽 == ProxyAdmin 地址;
  6. test_RenounceMakesImmutable:ProxyAdmin owner 设为 0 后,再无法升级。

Sepolia 部署与验证步骤

  1. 部署 VaultV1、ProxyAdmin、TransparentVaultProxy(传 ProxyAdmin + VaultV1);
  2. Etherscan 上代理应被识别为 proxy,可 “Write as Proxy” 调 deposit;
  3. 部署 VaultV2,用 ProxyAdmin.owner 调 upgradeAndCall(proxy, V2, initData)
  4. version() 返回 2,balanceOf 仍是升级前的值;
  5. cast storage <proxy> 0xb531...6103 读 admin 插槽核对。

进阶挑战(可选)

  • 把同一套实现改造成 UUPS 版本(升级函数放实现,带 _authorizeUpgrade),对比两者代理部署 Gas;
  • 故意部署一个”忘了带升级函数”的 UUPS 新实现,复现升级后永久锁死的事故,理解 Transparent 为何更稳。

💬 评论