十二、从源码讲解compound v2 代理升级架构

tomenengr 发布于 2026-05-05 阅读 156

代理升级架构

第十二讲继续:代理升级架构:Unitroller / Comptroller,CErc20Delegator / CErc20Delegate

这一讲非常重要,因为 Compound v2 不是简单地“部署一堆不可变合约”。它用了代理升级模式,让核心逻辑可以升级,同时保留原来的合约地址和状态。

也就是说,这一讲解决的是:

合约已经部署了,用户资产和状态都在链上,
如果以后要修 bug、加逻辑、改风控代码,
Compound v2 怎么升级?

第十二讲:代理升级架构

简介

本文精读 Compound v2 的代理升级架构,梳理 Unitroller -> ComptrollerCErc20Delegator -> CErc20Delegate 两套代理如何通过 delegatecall 实现逻辑升级、地址不变和状态保留。读完可以理解代理合约与实现合约的分工、storage layout 为什么不能乱改,以及 Timelock / Governance 权限为什么是可升级协议的安全边界。

1. 为什么需要代理升级?

以太坊合约一旦部署,代码默认不可修改。

这对协议来说有两个选择:

方案一:完全不可升级
  优点:用户信任代码不可变
  缺点:出 bug 或要迭代时很麻烦

方案二:代理升级
  优点:逻辑可以升级
  缺点:治理/管理员权限更敏感

Compound v2 选择了代理模式。

核心思想是:

代理合约保存状态和地址
实现合约保存逻辑
用户始终调用代理合约
代理合约用 delegatecall 执行实现合约代码

这样升级时只需要更换实现合约地址,不需要迁移用户状态。


2. Compound v2 里有两套代理

Compound v2 里主要有两类代理架构:

Comptroller 代理:
  Unitroller -> Comptroller

cToken 代理:
  CErc20Delegator -> CErc20Delegate

也就是:

风控中心 Comptroller 有代理
ERC20 cToken 市场也有代理

其中 ETH 市场 CEther 通常比较特殊,不一定走同样的 Delegator / Delegate 架构。


3. Unitroller / Comptroller 总览

先看 Comptroller 这套:

用户 / CToken
   |
   v
Unitroller
   |
   | delegatecall
   v
Comptroller implementation

可以理解成:

Unitroller:
  代理合约
  保存 Comptroller 的状态
  保存当前 implementation 地址

Comptroller:
  实现合约
  保存业务逻辑
  例如 borrowAllowed、redeemAllowed、getAccountLiquidity

这句话非常关键:

状态在 Unitroller,逻辑在 Comptroller。

4. delegatecall 是什么?

普通 call:

A call B
代码在 B 中执行
读写 B 的 storage
msg.sender 通常是 A

delegatecall:

A delegatecall B
代码使用 B 的代码
但读写 A 的 storage
msg.sender 保持原始调用者

所以 Unitroller delegatecall Comptroller 时:

执行的是 Comptroller 的代码
读写的是 Unitroller 的 storage

这就实现了:

换掉 Comptroller implementation
但 Unitroller 里的状态还在

这也是所有代理模式的核心。


5. 一个简单类比

你可以把代理模式想成:

Unitroller = 身体和记忆
Comptroller = 大脑插件

换大脑插件时,身体和记忆不变。

但这也很危险:

新大脑必须知道旧身体的器官位置。

翻译成 Solidity 就是:

新 implementation 的 storage layout 必须和旧 implementation 兼容。

否则新逻辑可能把旧状态读错、写坏。


6. Unitroller 里通常有什么状态?

Unitroller 会保存代理相关状态,比如:

address public admin;
address public pendingAdmin;

address public comptrollerImplementation;
address public pendingComptrollerImplementation;

这些变量负责升级流程。

大概含义:

admin:
  当前管理员,通常后来由治理控制

pendingAdmin:
  待接受的新管理员

comptrollerImplementation:
  当前 Comptroller 逻辑合约

pendingComptrollerImplementation:
  待接受的新 Comptroller 逻辑合约

还有一个关键点:

真正的 Comptroller 状态也在 Unitroller 的 storage 里。

例如:

markets
accountAssets
oracle
closeFactorMantissa
liquidationIncentiveMantissa
pauseGuardian

逻辑合约里声明 these 变量,但实际读写发生在代理合约 storage 上。


7. ComptrollerStorage:为什么有一堆 Storage 合约?

Compound v2 源码里你会看到一些类似:

UnitrollerAdminStorage
ComptrollerV1Storage
ComptrollerV2Storage
ComptrollerV3Storage
...

这不是装饰品。

这是为了控制存储布局。

代理升级最怕的是:

旧逻辑里 slot 0 是 admin
新逻辑里 slot 0 变成 oracle

那一升级,整个协议就开始胡言乱语。 链上 storage 不会因为你变量改名就自动迁移。

所以 Compound 用继承的 Storage 合约固定变量顺序。

像这样:

contract UnitrollerAdminStorage {
    address public admin;
    address public pendingAdmin;
    address public comptrollerImplementation;
    address public pendingComptrollerImplementation;
}

contract ComptrollerV1Storage is UnitrollerAdminStorage {
    PriceOracle public oracle;
    uint public closeFactorMantissa;
    uint public liquidationIncentiveMantissa;
    mapping(address => Market) public markets;
}

新版本只能在后面加变量:

contract ComptrollerV2Storage is ComptrollerV1Storage {
    address public pauseGuardian;
}

不能随便插到中间,不能改已有变量类型。


8. 存储布局为什么不能乱改?

假设旧版本:

contract OldStorage {
    address admin;       // slot 0
    address oracle;      // slot 1
    uint closeFactor;    // slot 2
}

新版本错误地改成:

contract NewStorage {
    address admin;       // slot 0
    uint closeFactor;    // slot 1
    address oracle;      // slot 2
}

那升级后:

slot 1 原本存的是 oracle 地址
新逻辑却把它当 closeFactor 读

slot 2 原本存的是 closeFactor
新逻辑却把它当 oracle 地址读

结果就是:

价格预言机地址错了
抵押参数错了
风控直接爆炸

所以代理升级里有一条铁律:

只能追加 storage,不能重排、删除、改类型。

9. Unitroller 的升级流程

Compound 的升级通常不是管理员单方面直接切换,而是两步提交/接受。

大概流程:

1. admin 调用 Unitroller._setPendingImplementation(newImpl)
2. newImpl 调用 Unitroller._acceptImplementation()
3. Unitroller 把 comptrollerImplementation 设置为 newImpl

为什么要两步?

因为要确保新 implementation 合约知道自己要成为这个代理的实现,并且可以执行初始化或兼容性检查。

伪代码:

function _setPendingImplementation(address newPendingImplementation) public {
    require(msg.sender == admin);
    pendingComptrollerImplementation = newPendingImplementation;
}

function _acceptImplementation() public {
    require(msg.sender == pendingComptrollerImplementation);

    comptrollerImplementation = pendingComptrollerImplementation;
    pendingComptrollerImplementation = address(0);
}

重点:

只有 pending implementation 自己可以 accept。

10. Unitroller fallback

用户调用 Unitroller 上不存在的函数时,会进 fallback。

fallback 大概做:

fallback() external payable {
    address impl = comptrollerImplementation;

    assembly {
        calldatacopy(0, 0, calldatasize())

        let result := delegatecall(
            gas(),
            impl,
            0,
            calldatasize(),
            0,
            0
        )

        returndatacopy(0, 0, returndatasize())

        switch result
        case 0 { revert(0, returndatasize()) }
        default { return(0, returndatasize()) }
    }
}

这段看着吓人,但逻辑就是:

把用户的 calldata 原样转给 implementation
用 delegatecall 执行
把返回值原样返回给用户
如果失败,就原样 revert

所以外部看起来像是:

用户直接调用 Comptroller

但实际地址是:

Unitroller

11. 为什么 CToken 调的是 Unitroller?

每个 CToken 里会保存:

ComptrollerInterface public comptroller;

这个地址通常指向:

Unitroller 地址

而不是某个具体 Comptroller implementation。

所以 CToken 调:

comptroller.borrowAllowed(...)

实际上是调用 Unitroller。

Unitroller fallback 再 delegatecall 当前 Comptroller implementation。

这样一来,Comptroller 升级后,所有 CToken 不需要改地址。

cDAI 仍然指向同一个 Unitroller
Unitroller 背后的逻辑换了

这就是代理的价值。


12. CErc20Delegator / CErc20Delegate 总览

除了 Comptroller,Compound v2 的 ERC20 cToken 市场也用了代理。

结构:

用户
 |
 v
CErc20Delegator
 |
 | delegatecall
 v
CErc20Delegate implementation

可以理解成:

CErc20Delegator:
  代理合约
  是用户交互的 cToken 地址
  保存该市场状态
  也是 ERC20 token 地址

CErc20Delegate:
  逻辑合约
  实现 mint / redeem / borrow / repay / liquidate 等逻辑

比如:

cDAI 的地址 = CErc20Delegator 地址

用户持有的 cDAI ERC20 token 就是这个代理地址上的余额状态。

逻辑可以换,但 token 地址不变。


13. 为什么 cToken 也要代理?

因为每个 cToken 市场都有大量状态:

accountTokens
accountBorrows
totalSupply
totalBorrows
totalReserves
borrowIndex
accrualBlockNumber
interestRateModel
comptroller
underlying

如果某个市场逻辑要修 bug 或升级,不可能让所有用户迁移到新 cToken 地址。

那会非常痛苦:

用户余额要迁移
抵押关系要迁移
借款快照要迁移
集成协议要改地址
前端要改地址
流动性和信任都断裂

代理可以让:

cDAI 地址不变
用户 cDAI 余额不变
借款状态不变
只是背后逻辑升级

14. CErc20Delegator 里有什么?

CErc20Delegator 通常继承 CToken 存储结构,并额外保存 implementation:

address public implementation;

它的构造函数会初始化市场:

underlying
comptroller
interestRateModel
initialExchangeRateMantissa
name
symbol
decimals
admin
implementation

也就是说,部署一个 cToken 市场时,Delegator 就是最终用户看到的 cToken。


15. CErc20Delegate 里有什么?

CErc20Delegate 是逻辑实现。

它实现用户入口:

function mint(uint mintAmount) external returns (uint) {
    mintInternal(mintAmount);
    return NO_ERROR;
}

function redeem(uint redeemTokens) external returns (uint) {
    redeemInternal(redeemTokens);
    return NO_ERROR;
}

function borrow(uint borrowAmount) external returns (uint) {
    borrowInternal(borrowAmount);
    return NO_ERROR;
}

但由于是 delegatecall,执行这些逻辑时,读写的是 Delegator 的 storage。

所以:

CErc20Delegate 不是 cDAI 本体
CErc20Delegator 才是 cDAI 本体

Delegate 更像一套可替换的“代码模块”。


16. cToken 的 delegateToImplementation

Delegator 通常会有类似:

function delegateToImplementation(bytes memory data) public returns (bytes memory) {
    (bool success, bytes memory returnData) =
        implementation.delegatecall(data);

    require(success);
    return returnData;
}

或者用 assembly 做更底层的转发。

当用户调用 Delegator 上没有直接实现的函数时,fallback 会把调用转给 implementation。

也就是说:

用户调用 cDAI.mint()
  -> CErc20Delegator fallback
  -> delegatecall CErc20Delegate.mint()
  -> mintInternal()
  -> 修改 CErc20Delegator storage

17. _setImplementation:升级 cToken 逻辑

cToken 代理通常有管理员函数:

function _setImplementation(
    address implementation_,
    bool allowResign,
    bytes memory becomeImplementationData
) public

大概流程:

1. 只有 admin 可以调用
2. 如果 allowResign,旧 implementation 执行 _resignImplementation
3. 设置新的 implementation
4. 新 implementation 执行 _becomeImplementation

为什么有 _becomeImplementation

因为新逻辑可能需要初始化一些东西,或者检查参数。

比如:

新实现是否兼容这个市场?
是否需要设置额外参数?
是否要初始化新版本状态?

这也是一种安全握手。


18. _becomeImplementation / _resignImplementation

Delegate 合约里通常会有:

function _becomeImplementation(bytes memory data) public {
    require(msg.sender == admin);
    // initialization / checks
}

function _resignImplementation() public {
    require(msg.sender == admin);
    // cleanup if needed
}

因为 delegatecall 上下文里,admin 读的是 Delegator 的 storage。

这两个函数是 implementation 升级生命周期钩子:

_becomeImplementation:
  新逻辑接任时调用

_resignImplementation:
  旧逻辑卸任时调用

不是每次都需要复杂逻辑,但接口留着方便升级。


19. CErc20Delegator 和 Unitroller 的区别

两者都用代理,但风格略有不同.

Unitroller / Comptroller:
  代理 Comptroller 风控中心
  使用 pending / accept 两步升级
  主要由治理控制

CErc20Delegator / CErc20Delegate:
  代理单个 ERC20 cToken 市场
  通过 _setImplementation 设置实现
  每个市场都有自己的 Delegator

共同点:

代理保存状态
实现保存逻辑
delegatecall 执行
升级时换 implementation
storage layout 必须兼容

区别:

Unitroller 是全局风控代理
CErc20Delegator 是单市场资产代理

20. 为什么 CEther 可能不一样?

CEther 处理的是原生 ETH,不是 ERC20 underlying。

它有一些特殊逻辑:

msg.value
payable
直接发送 ETH
没有 underlying ERC20 地址

所以它的部署和代理模式可能不像 CErc20Delegator / CErc20Delegate 那样统一。

读源码时你会发现:

CErc20 市场通常走 Delegator / Delegate
CEther 更像单独实现

这不是设计混乱,而是 ETH 原生资产在 Solidity 里确实特殊。


21. 代理模式里的 msg.sender

这是 delegatecall 的一个关键点。

用户 Alice 调用:

cDAI.mint(100)

真实链路:

Alice
 -> CErc20Delegator
 -> delegatecall CErc20Delegate

在 Delegate 逻辑中:

msg.sender == Alice

不是 Delegator。

这是 delegatecall 的特性。

如果 msg.sender 变成代理合约,很多逻辑都会坏掉:

mint 会认为 minter 是代理
borrow 会认为 borrower 是代理
repay 会从代理那里拉钱

delegatecall 保留原始 msg.sender,正好适合代理模式。


22. 代理模式里的 address(this)

delegatecall 还有一个重要特性:

在 implementation 代码里:

address(this)

指的是代理地址,不是 implementation 地址。

所以在 CErc20Delegate 逻辑里:

address(this)

其实是 cDAI Delegator 地址。

这非常关键。

例如:

comptroller.borrowAllowed(address(this), borrower, borrowAmount)

如果是在 cDAI 代理上下文中执行,address(this) 就是 cDAI 市场地址。

这正是我们想要的。


23. 代理升级的最大风险:恶意实现

代理模式的灵活性也带来风险。

如果 admin / governance 设置了恶意 implementation,它可以:

改用户余额
偷 underlying
改借款状态
破坏风控
阻止用户操作

因为 implementation 通过 delegatecall 可以读写代理的全部 storage。

所以代理系统的真正安全边界是:

治理权限 / admin 权限
升级流程
Timelock 延迟
社区审查
代码审计

可升级协议不是“代码不可作恶”,而是“升级权力受治理约束”。


24. Timelock 和治理为什么重要?

Compound v2 后续治理体系里,关键权限通常会交给 Timelock / Governor。

升级流程一般不是某个 EOA 一键改。

理想路径是:

治理提案
  -> 投票
  -> Timelock 排队
  -> 延迟期
  -> 执行升级

Timelock 的意义是:

让用户和市场有时间看到即将发生的高权限操作。

如果治理要升级恶意实现,用户至少有时间退出。

当然,这依然依赖治理安全。 DeFi 里“可升级”这件事永远不是白送午餐。


25. 代理模式和 ABI

用户和前端通常拿的是 implementation 的 ABI,但调用地址是代理地址。

比如:

ABI: Comptroller ABI
Address: Unitroller address

或者:

ABI: CErc20Delegate / CToken ABI
Address: cDAI Delegator address

这是代理合约开发里很常见的模式。

因为代理本身可能只有 fallback,看起来没有那么多函数。 但 fallback 会把这些函数转给 implementation。

所以用 implementation ABI 调代理地址是合理的。


26. 如何从源码判断状态在哪里?

一个简单原则:

谁被用户长期持有地址,状态就在谁那里。

例如:

Comptroller 地址实际是 Unitroller
所以 Comptroller 状态在 Unitroller

cDAI 地址实际是 CErc20Delegator
所以 cDAI 状态在 CErc20Delegator

Implementation 合约可以看成“代码库”。

它自己即使也有 storage slot,从正常协议路径看,通常不是你关心的状态。


27. 代理模式下读源码的技巧

读代理源码时要在脑子里做替换。

看到 Comptroller 实现里的:

markets[cToken]
oracle
closeFactorMantissa

要想:

这些变量实际存储在 Unitroller 上。

看到 CErc20Delegate 里的:

accountTokens[account]
totalBorrows
borrowIndex
underlying

要想:

这些变量实际存储在 CErc20Delegator 上。

看到:

address(this)

要想:

这是代理地址。

看到:

msg.sender

要想:

这是原始调用者。

这四个替换做对,代理源码就不迷路。


28. 一次 cDAI.mint 的代理链路

把它完整串一下:

Alice 调用 cDAI.mint(100)
 |
 v
cDAI 地址 = CErc20Delegator
 |
 | fallback / delegateToImplementation
 v
delegatecall CErc20Delegate.mint(100)
 |
 v
mintInternal(100)
 |
 |-- accrueInterest()
 |-- mintFresh(Alice, 100)
 |
 |-- 读写的是 CErc20Delegator storage:
 |     totalSupply
 |     accountTokens[Alice]
 |     totalBorrows
 |     borrowIndex
 |
 |-- address(this) = cDAI Delegator 地址
 |-- msg.sender = Alice

这就是代理下的真实执行模型。


29. 一次 cDAI.borrowAllowed 的双代理链路

更刺激一点,borrow 会跨两套代理:

Alice 调用 cDAI.borrow(100)
 |
 v
CErc20Delegator(cDAI)
 |
 | delegatecall
 v
CErc20Delegate.borrow
 |
 v
borrowFresh
 |
 |-- comptroller.borrowAllowed(address(this), Alice, 100)
       |
       v
       comptroller 地址 = Unitroller
       |
       | fallback / delegatecall
       v
       Comptroller implementation.borrowAllowed
       |
       |-- 读写 Unitroller storage:
       |     markets
       |     accountAssets
       |     oracle
       |     pauseGuardian

所以一次 borrow 实际涉及:

cToken 代理
cToken 实现
Comptroller 代理
Comptroller 实现
Oracle
InterestRateModel
Underlying ERC20

30. 代理模式下的存储布局规则

最后把规则钉牢:

1. 代理保存状态,实现保存逻辑。
2. delegatecall 执行实现代码,但读写代理 storage。
3. implementation 升级不能破坏 storage layout。
4. 旧变量不能删除。
5. 旧变量类型不能改。
6. 旧变量顺序不能改。
7. 新变量只能追加到后面。
8. 初始化逻辑要通过专门函数或升级 hook 执行。
9. admin / governance 权限是最高风险点。
10. ABI 通常用 implementation ABI,地址用 proxy 地址。

31. 第十二讲要记住的 8 个结论

第一,Compound v2 使用代理升级,让逻辑可替换、状态和地址保持不变。

第二,Comptroller 的代理是:

Unitroller -> Comptroller implementation

第三,ERC20 cToken 的代理是:

CErc20Delegator -> CErc20Delegate

第四,delegatecall 的核心特性是:

执行 implementation 代码
读写 proxy storage
保留原始 msg.sender
address(this) 是 proxy 地址

第五,代理升级最重要的安全规则是 storage layout 兼容。

第六,Unitroller 使用 pending / accept 模式升级 Comptroller implementation。

第七,CErc20Delegator 使用 _setImplementation 更换 cToken 逻辑,并可调用 _becomeImplementation / _resignImplementation

第八,可升级性带来的最大风险是治理或 admin 权限,所以 Timelock / Governor 是安全边界的重要部分。


下一讲我们读 Governance:COMP / GovernorAlpha / Timelock

它会回答:

COMP 代币怎么参与治理?
提案怎么创建?
投票权怎么计算?
为什么要 checkpoint?
Timelock 为什么重要?
治理如何升级 Comptroller 或调整市场参数?

这部分读完,你就能把 Compound v2 从“借贷合约”扩展到“链上治理协议”。

相关文章

0 条评论